Add guest push notifications and queue alerts
This commit is contained in:
15
.env.example
15
.env.example
@@ -138,4 +138,19 @@ COOLIFY_WEB_URL=
|
||||
COOLIFY_API_TIMEOUT=5
|
||||
COOLIFY_SERVICE_IDS={"app":"svc_app","queue":"svc_queue","scheduler":"svc_scheduler","ftp":"svc_ftp","control":"svc_control"}
|
||||
|
||||
GUEST_ACHIEVEMENT_MILESTONES=10,25,50
|
||||
|
||||
# Push notifications
|
||||
PUSH_ENABLED=false
|
||||
PUSH_VAPID_PUBLIC_KEY=
|
||||
PUSH_VAPID_PRIVATE_KEY=
|
||||
PUSH_VAPID_SUBJECT="mailto:hello@example.com"
|
||||
|
||||
# Storage queue guest alert tuning
|
||||
STORAGE_QUEUE_PENDING_EVENT_THRESHOLD=5
|
||||
STORAGE_QUEUE_PENDING_EVENT_MINUTES=8
|
||||
STORAGE_QUEUE_FAILED_EVENT_THRESHOLD=2
|
||||
STORAGE_QUEUE_FAILED_EVENT_MINUTES=30
|
||||
STORAGE_QUEUE_GUEST_ALERT_TTL=30
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Console\Concerns\InteractsWithCacheLocks;
|
||||
use App\Enums\GuestNotificationAudience;
|
||||
use App\Enums\GuestNotificationType;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventMediaAsset;
|
||||
use App\Services\GuestNotificationService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Contracts\Cache\Lock;
|
||||
use Illuminate\Queue\QueueManager;
|
||||
@@ -20,6 +24,11 @@ class CheckUploadQueuesCommand extends Command
|
||||
|
||||
protected $description = 'Inspect upload-related queues and flag stalled or overloaded workers.';
|
||||
|
||||
public function __construct(private readonly GuestNotificationService $guestNotifications)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(QueueManager $queueManager): int
|
||||
{
|
||||
$lockSeconds = (int) config('storage-monitor.queue_health.lock_seconds', 120);
|
||||
@@ -117,6 +126,8 @@ class CheckUploadQueuesCommand extends Command
|
||||
count($alerts)
|
||||
));
|
||||
|
||||
$this->maybeNotifyGuests($alerts);
|
||||
|
||||
return self::SUCCESS;
|
||||
} finally {
|
||||
if ($lock instanceof Lock) {
|
||||
@@ -125,6 +136,130 @@ class CheckUploadQueuesCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
private function maybeNotifyGuests(array $alerts): void
|
||||
{
|
||||
if (empty($alerts)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatchPendingAlerts();
|
||||
$this->dispatchFailureAlerts();
|
||||
}
|
||||
|
||||
private function dispatchPendingAlerts(): void
|
||||
{
|
||||
$threshold = max(1, (int) config('storage-monitor.queue_health.pending_event_threshold', 5));
|
||||
$minutes = max(1, (int) config('storage-monitor.queue_health.pending_event_minutes', 8));
|
||||
|
||||
$pending = EventMediaAsset::query()
|
||||
->selectRaw('event_id, COUNT(*) as pending_count, MIN(created_at) as oldest_created_at')
|
||||
->where('status', 'pending')
|
||||
->where('created_at', '<=', now()->subMinutes($minutes))
|
||||
->groupBy('event_id')
|
||||
->havingRaw('COUNT(*) >= ?', [$threshold])
|
||||
->limit(50)
|
||||
->get();
|
||||
|
||||
foreach ($pending as $row) {
|
||||
$event = Event::query()->find($row->event_id);
|
||||
|
||||
if (! $event) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$title = 'Uploads werden noch verarbeitet …';
|
||||
if ($this->recentlySentAlert($event->id, $title)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$count = (int) $row->pending_count;
|
||||
$body = $count > 1
|
||||
? sprintf('%d Fotos stehen noch in der Warteschlange. Wir sagen Bescheid, sobald alles gespeichert ist.', $count)
|
||||
: 'Ein Upload-Schub wird gerade verarbeitet. Danke für deine Geduld!';
|
||||
|
||||
$this->guestNotifications->createNotification(
|
||||
$event,
|
||||
GuestNotificationType::UPLOAD_ALERT,
|
||||
$title,
|
||||
$body,
|
||||
[
|
||||
'audience_scope' => GuestNotificationAudience::ALL,
|
||||
'priority' => 1,
|
||||
'expires_at' => now()->addMinutes(90),
|
||||
]
|
||||
);
|
||||
|
||||
$this->rememberAlert($event->id, $title);
|
||||
}
|
||||
}
|
||||
|
||||
private function dispatchFailureAlerts(): void
|
||||
{
|
||||
$threshold = max(1, (int) config('storage-monitor.queue_health.failed_event_threshold', 2));
|
||||
$minutes = max(1, (int) config('storage-monitor.queue_health.failed_event_minutes', 30));
|
||||
|
||||
$failed = EventMediaAsset::query()
|
||||
->selectRaw('event_id, COUNT(*) as failed_count')
|
||||
->where('status', 'failed')
|
||||
->where('updated_at', '>=', now()->subMinutes($minutes))
|
||||
->groupBy('event_id')
|
||||
->havingRaw('COUNT(*) >= ?', [$threshold])
|
||||
->limit(50)
|
||||
->get();
|
||||
|
||||
foreach ($failed as $row) {
|
||||
$event = Event::query()->find($row->event_id);
|
||||
|
||||
if (! $event) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$title = 'Einige Uploads mussten neu gestartet werden';
|
||||
if ($this->recentlySentAlert($event->id, $title)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$count = (int) $row->failed_count;
|
||||
$body = $count > 1
|
||||
? sprintf('%d Fotos wurden automatisch erneut angestoßen. Bitte öffne kurz die App, falls deine Uploads hängen.', $count)
|
||||
: 'Ein Upload wurde neu gestartet. Öffne bitte kurz die App, damit nichts verloren geht.';
|
||||
|
||||
$this->guestNotifications->createNotification(
|
||||
$event,
|
||||
GuestNotificationType::SUPPORT_TIP,
|
||||
$title,
|
||||
$body,
|
||||
[
|
||||
'audience_scope' => GuestNotificationAudience::ALL,
|
||||
'priority' => 2,
|
||||
'expires_at' => now()->addHours(2),
|
||||
]
|
||||
);
|
||||
|
||||
$this->rememberAlert($event->id, $title);
|
||||
}
|
||||
}
|
||||
|
||||
private function recentlySentAlert(int $eventId, string $title): bool
|
||||
{
|
||||
$key = $this->alertCacheKey($eventId, $title);
|
||||
|
||||
return Cache::has($key);
|
||||
}
|
||||
|
||||
private function rememberAlert(int $eventId, string $title): void
|
||||
{
|
||||
$key = $this->alertCacheKey($eventId, $title);
|
||||
$ttl = max(5, (int) config('storage-monitor.queue_health.guest_alert_ttl', 30));
|
||||
|
||||
Cache::put($key, true, now()->addMinutes($ttl));
|
||||
}
|
||||
|
||||
private function alertCacheKey(int $eventId, string $title): string
|
||||
{
|
||||
return sprintf('guest-queue-alert:%d:%s', $eventId, sha1($title));
|
||||
}
|
||||
|
||||
private function readQueueSize(QueueManager $manager, ?string $connection, string $queue): int
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -19,6 +19,7 @@ use App\Services\EventTasksCacheService;
|
||||
use App\Services\GuestNotificationService;
|
||||
use App\Services\Packages\PackageLimitEvaluator;
|
||||
use App\Services\Packages\PackageUsageTracker;
|
||||
use App\Services\PushSubscriptionService;
|
||||
use App\Services\Storage\EventStorageManager;
|
||||
use App\Support\ApiError;
|
||||
use App\Support\ImageHelper;
|
||||
@@ -49,6 +50,7 @@ class EventPublicController extends BaseController
|
||||
private readonly PackageUsageTracker $packageUsageTracker,
|
||||
private readonly EventTasksCacheService $eventTasksCache,
|
||||
private readonly GuestNotificationService $guestNotificationService,
|
||||
private readonly PushSubscriptionService $pushSubscriptions,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -1666,6 +1668,69 @@ class EventPublicController extends BaseController
|
||||
->header('Vary', 'X-Device-Id, Accept-Language');
|
||||
}
|
||||
|
||||
public function registerPushSubscription(Request $request, string $token)
|
||||
{
|
||||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||||
|
||||
if ($result instanceof JsonResponse) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
[$eventRecord] = $result;
|
||||
$validated = $request->validate([
|
||||
'endpoint' => ['required', 'url', 'max:500'],
|
||||
'keys.p256dh' => ['required', 'string', 'max:255'],
|
||||
'keys.auth' => ['required', 'string', 'max:255'],
|
||||
'expiration_time' => ['nullable'],
|
||||
'content_encoding' => ['nullable', 'string', 'max:32'],
|
||||
]);
|
||||
|
||||
$event = Event::findOrFail($eventRecord->id);
|
||||
$guestIdentifier = $this->resolveNotificationIdentifier($request);
|
||||
$deviceId = $this->resolveDeviceIdentifier($request);
|
||||
|
||||
$payload = [
|
||||
'endpoint' => $validated['endpoint'],
|
||||
'keys' => [
|
||||
'p256dh' => $validated['keys']['p256dh'],
|
||||
'auth' => $validated['keys']['auth'],
|
||||
],
|
||||
'expiration_time' => $validated['expiration_time'] ?? null,
|
||||
'content_encoding' => $validated['content_encoding'] ?? null,
|
||||
'language' => $request->getPreferredLanguage() ?? $request->headers->get('Accept-Language'),
|
||||
'user_agent' => (string) $request->userAgent(),
|
||||
];
|
||||
|
||||
$subscription = $this->pushSubscriptions->register($event, $guestIdentifier, $deviceId, $payload);
|
||||
|
||||
return response()->json([
|
||||
'id' => $subscription->id,
|
||||
'status' => $subscription->status,
|
||||
], Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
public function destroyPushSubscription(Request $request, string $token)
|
||||
{
|
||||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||||
|
||||
if ($result instanceof JsonResponse) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
[$eventRecord] = $result;
|
||||
|
||||
$validated = $request->validate([
|
||||
'endpoint' => ['required', 'url', 'max:500'],
|
||||
]);
|
||||
|
||||
$event = Event::findOrFail($eventRecord->id);
|
||||
$revoked = $this->pushSubscriptions->revoke($event, $validated['endpoint']);
|
||||
|
||||
return response()->json([
|
||||
'status' => $revoked ? 'revoked' : 'not_found',
|
||||
]);
|
||||
}
|
||||
|
||||
public function markNotificationRead(Request $request, string $token, GuestNotification $notification)
|
||||
{
|
||||
return $this->handleNotificationAction($request, $token, $notification, 'read');
|
||||
@@ -1831,10 +1896,16 @@ class EventPublicController extends BaseController
|
||||
return $identifier;
|
||||
}
|
||||
|
||||
$deviceId = (string) $request->headers->get('X-Device-Id', '');
|
||||
$deviceId = substr(preg_replace('/[^A-Za-z0-9 _\-]/', '', $deviceId) ?? '', 0, 120);
|
||||
return $this->resolveDeviceIdentifier($request);
|
||||
}
|
||||
|
||||
return $deviceId !== '' ? $deviceId : 'anonymous';
|
||||
private function resolveDeviceIdentifier(Request $request): string
|
||||
{
|
||||
$deviceId = (string) $request->headers->get('X-Device-Id', '');
|
||||
$normalized = preg_replace('/[^A-Za-z0-9 _\-]/', '', $deviceId) ?? '';
|
||||
$normalized = trim(substr($normalized, 0, 120));
|
||||
|
||||
return $normalized !== '' ? $normalized : 'anonymous';
|
||||
}
|
||||
|
||||
public function stats(Request $request, string $token)
|
||||
@@ -2216,8 +2287,7 @@ class EventPublicController extends BaseController
|
||||
$eventPackage = $this->packageLimitEvaluator
|
||||
->resolveEventPackageForPhotoUpload($tenantModel, $eventId, $eventModel);
|
||||
|
||||
$deviceId = (string) $request->header('X-Device-Id', 'anon');
|
||||
$deviceId = substr(preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId), 0, 64) ?: 'anon';
|
||||
$deviceId = $this->resolveDeviceIdentifier($request);
|
||||
|
||||
// Per-device cap per event (MVP: 50)
|
||||
$deviceCount = DB::table('photos')->where('event_id', $eventId)->where('guest_name', $deviceId)->count();
|
||||
|
||||
100
app/Jobs/SendGuestPushNotificationBatch.php
Normal file
100
app/Jobs/SendGuestPushNotificationBatch.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\GuestNotification;
|
||||
use App\Models\PushSubscription;
|
||||
use App\Services\Push\WebPushDispatcher;
|
||||
use App\Services\PushSubscriptionService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SendGuestPushNotificationBatch implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
/**
|
||||
* @param int[] $subscriptionIds
|
||||
*/
|
||||
public function __construct(
|
||||
public int $notificationId,
|
||||
public array $subscriptionIds
|
||||
) {
|
||||
$this->onQueue('notifications');
|
||||
}
|
||||
|
||||
public function handle(
|
||||
WebPushDispatcher $dispatcher,
|
||||
PushSubscriptionService $subscriptions
|
||||
): void {
|
||||
if (! config('push.enabled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var GuestNotification|null $notification */
|
||||
$notification = GuestNotification::query()->find($this->notificationId);
|
||||
|
||||
if (! $notification) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var Collection<int, PushSubscription> $targets */
|
||||
$targets = PushSubscription::query()
|
||||
->whereIn('id', $this->subscriptionIds)
|
||||
->where('status', 'active')
|
||||
->get();
|
||||
|
||||
if ($targets->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'title' => $notification->title,
|
||||
'body' => $notification->body,
|
||||
'data' => [
|
||||
'notification_id' => $notification->id,
|
||||
'event_id' => $notification->event_id,
|
||||
'type' => $notification->type->value,
|
||||
'cta' => $notification->payload['cta'] ?? null,
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($targets as $target) {
|
||||
try {
|
||||
$report = $dispatcher->send($target, $payload);
|
||||
|
||||
if ($report === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($report->isSuccess()) {
|
||||
$subscriptions->markDelivered($target);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($report->isSubscriptionExpired()) {
|
||||
$target->update(['status' => 'revoked']);
|
||||
}
|
||||
|
||||
$subscriptions->markFailed($target, $report->getReason());
|
||||
} catch (\Throwable $exception) {
|
||||
Log::channel('notifications')->warning('Web push delivery failed', [
|
||||
'subscription_id' => $target->id,
|
||||
'event_id' => $notification->event_id,
|
||||
'reason' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
$subscriptions->markFailed($target, $exception->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
app/Listeners/DispatchGuestNotificationPush.php
Normal file
41
app/Listeners/DispatchGuestNotificationPush.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Enums\GuestNotificationAudience;
|
||||
use App\Events\GuestNotificationCreated;
|
||||
use App\Jobs\SendGuestPushNotificationBatch;
|
||||
use App\Models\PushSubscription;
|
||||
|
||||
class DispatchGuestNotificationPush
|
||||
{
|
||||
public function handle(GuestNotificationCreated $event): void
|
||||
{
|
||||
if (! config('push.enabled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$notification = $event->notification;
|
||||
$query = PushSubscription::query()
|
||||
->where('event_id', $notification->event_id)
|
||||
->where('status', 'active');
|
||||
|
||||
if ($notification->audience_scope === GuestNotificationAudience::GUEST && $notification->target_identifier) {
|
||||
$target = $notification->target_identifier;
|
||||
$query->where(function ($builder) use ($target) {
|
||||
$builder->where('guest_identifier', $target)
|
||||
->orWhere('device_id', $target);
|
||||
});
|
||||
}
|
||||
|
||||
$subscriptionIds = $query->pluck('id')->all();
|
||||
|
||||
if ($subscriptionIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (array_chunk($subscriptionIds, 50) as $chunk) {
|
||||
SendGuestPushNotificationBatch::dispatch($notification->id, $chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ class SendPhotoUploadedNotification
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly GuestNotificationService $notifications,
|
||||
private readonly array $milestones = [5, 10, 20]
|
||||
private readonly array $milestones = []
|
||||
) {}
|
||||
|
||||
public function handle(GuestPhotoUploaded $event): void
|
||||
@@ -52,7 +52,9 @@ class SendPhotoUploadedNotification
|
||||
->where('guest_name', $event->guestIdentifier)
|
||||
->count();
|
||||
|
||||
if (! in_array($count, $this->milestones, true)) {
|
||||
$milestones = $this->milestones ?: config('notifications.guest_achievements.milestones', [10, 25, 50]);
|
||||
|
||||
if (! in_array($count, $milestones, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
51
app/Models/PushSubscription.php
Normal file
51
app/Models/PushSubscription.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PushSubscription extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'event_id',
|
||||
'guest_identifier',
|
||||
'device_id',
|
||||
'endpoint',
|
||||
'endpoint_hash',
|
||||
'public_key',
|
||||
'auth_token',
|
||||
'content_encoding',
|
||||
'status',
|
||||
'expires_at',
|
||||
'last_seen_at',
|
||||
'last_notified_at',
|
||||
'last_failed_at',
|
||||
'failure_count',
|
||||
'language',
|
||||
'user_agent',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'expires_at' => 'datetime',
|
||||
'last_seen_at' => 'datetime',
|
||||
'last_notified_at' => 'datetime',
|
||||
'last_failed_at' => 'datetime',
|
||||
'meta' => 'array',
|
||||
];
|
||||
|
||||
public function event(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Event::class);
|
||||
}
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Events\GuestNotificationCreated;
|
||||
use App\Events\GuestPhotoUploaded;
|
||||
use App\Events\Packages\EventPackageGalleryExpired;
|
||||
use App\Events\Packages\EventPackageGalleryExpiring;
|
||||
@@ -14,6 +15,7 @@ use App\Events\Packages\TenantPackageEventLimitReached;
|
||||
use App\Events\Packages\TenantPackageEventThresholdReached;
|
||||
use App\Events\Packages\TenantPackageExpired;
|
||||
use App\Events\Packages\TenantPackageExpiring;
|
||||
use App\Listeners\DispatchGuestNotificationPush;
|
||||
use App\Listeners\GuestNotifications\SendPhotoUploadedNotification;
|
||||
use App\Listeners\Packages\QueueGalleryExpiredNotification;
|
||||
use App\Listeners\Packages\QueueGalleryWarningNotification;
|
||||
@@ -125,6 +127,11 @@ class AppServiceProvider extends ServiceProvider
|
||||
[SendPhotoUploadedNotification::class, 'handle']
|
||||
);
|
||||
|
||||
EventFacade::listen(
|
||||
GuestNotificationCreated::class,
|
||||
[DispatchGuestNotificationPush::class, 'handle']
|
||||
);
|
||||
|
||||
RateLimiter::for('tenant-api', function (Request $request) {
|
||||
$tenantId = $request->attributes->get('tenant_id')
|
||||
?? $request->user()?->tenant_id
|
||||
|
||||
82
app/Services/Push/WebPushDispatcher.php
Normal file
82
app/Services/Push/WebPushDispatcher.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Push;
|
||||
|
||||
use App\Models\PushSubscription;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Minishlink\WebPush\MessageSentReport;
|
||||
use Minishlink\WebPush\Subscription as WebPushSubscription;
|
||||
use Minishlink\WebPush\WebPush;
|
||||
|
||||
class WebPushDispatcher
|
||||
{
|
||||
private ?WebPush $client = null;
|
||||
|
||||
public function send(PushSubscription $subscription, array $payload): ?MessageSentReport
|
||||
{
|
||||
if (! config('push.enabled')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$client = $this->client ??= $this->buildClient();
|
||||
|
||||
if (! $client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $exception) {
|
||||
Log::channel('notifications')->warning('Unable to encode push payload', [
|
||||
'reason' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
$body = '{}';
|
||||
}
|
||||
|
||||
try {
|
||||
return $client->sendOneNotification(
|
||||
WebPushSubscription::create([
|
||||
'endpoint' => $subscription->endpoint,
|
||||
'publicKey' => $subscription->public_key,
|
||||
'authToken' => $subscription->auth_token,
|
||||
'contentEncoding' => $subscription->content_encoding ?? 'aes128gcm',
|
||||
]),
|
||||
$body
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::channel('notifications')->warning('Web push transport error', [
|
||||
'event_id' => $subscription->event_id,
|
||||
'subscription_id' => $subscription->id,
|
||||
'reason' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function buildClient(): ?WebPush
|
||||
{
|
||||
$vapid = config('push.vapid', []);
|
||||
|
||||
if (empty($vapid['public_key']) || empty($vapid['private_key'])) {
|
||||
Log::channel('notifications')->warning('Web push skipped because VAPID keys are missing.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$client = new WebPush([
|
||||
'VAPID' => [
|
||||
'subject' => $vapid['subject'] ?? config('app.url'),
|
||||
'publicKey' => $vapid['public_key'],
|
||||
'privateKey' => $vapid['private_key'],
|
||||
],
|
||||
]);
|
||||
|
||||
$client->setDefaultOptions([
|
||||
'TTL' => (int) config('push.ttl', 900),
|
||||
]);
|
||||
|
||||
return $client;
|
||||
}
|
||||
}
|
||||
153
app/Services/PushSubscriptionService.php
Normal file
153
app/Services/PushSubscriptionService.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\PushSubscription;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class PushSubscriptionService
|
||||
{
|
||||
public function register(Event $event, string $guestIdentifier, string $deviceId, array $payload): PushSubscription
|
||||
{
|
||||
$endpoint = (string) ($payload['endpoint'] ?? '');
|
||||
if ($endpoint === '') {
|
||||
throw new \InvalidArgumentException('Push endpoint missing.');
|
||||
}
|
||||
|
||||
$keys = Arr::get($payload, 'keys', []);
|
||||
$publicKey = (string) ($keys['p256dh'] ?? '');
|
||||
$authToken = (string) ($keys['auth'] ?? '');
|
||||
|
||||
if ($publicKey === '' || $authToken === '') {
|
||||
throw new \InvalidArgumentException('Push key material missing.');
|
||||
}
|
||||
|
||||
$contentEncoding = (string) ($payload['content_encoding'] ?? Arr::get($payload, 'encoding', 'aes128gcm'));
|
||||
$language = (string) ($payload['language'] ?? null);
|
||||
$userAgent = (string) ($payload['user_agent'] ?? null);
|
||||
$expiresAt = $this->normalizeExpiration(Arr::get($payload, 'expiration_time'));
|
||||
|
||||
$endpointHash = hash('sha256', $endpoint);
|
||||
$data = [
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->getKey(),
|
||||
'guest_identifier' => $this->sanitizeIdentifier($guestIdentifier),
|
||||
'device_id' => $this->sanitizeIdentifier($deviceId) ?? 'anonymous',
|
||||
'public_key' => $publicKey,
|
||||
'auth_token' => $authToken,
|
||||
'content_encoding' => $contentEncoding ?: 'aes128gcm',
|
||||
'status' => 'active',
|
||||
'expires_at' => $expiresAt,
|
||||
'last_seen_at' => now(),
|
||||
'language' => $language !== '' ? substr($language, 0, 12) : null,
|
||||
'user_agent' => $userAgent !== '' ? substr($userAgent, 0, 255) : null,
|
||||
'failure_count' => 0,
|
||||
];
|
||||
|
||||
/** @var PushSubscription $subscription */
|
||||
$subscription = PushSubscription::query()
|
||||
->where('endpoint_hash', $endpointHash)
|
||||
->first();
|
||||
|
||||
if ($subscription) {
|
||||
$subscription->fill($data);
|
||||
$subscription->status = 'active';
|
||||
$subscription->endpoint = $endpoint;
|
||||
$subscription->save();
|
||||
|
||||
return $subscription;
|
||||
}
|
||||
|
||||
return PushSubscription::create(array_merge($data, [
|
||||
'endpoint' => $endpoint,
|
||||
'endpoint_hash' => $endpointHash,
|
||||
]));
|
||||
}
|
||||
|
||||
public function revoke(Event $event, string $endpoint): bool
|
||||
{
|
||||
$hash = hash('sha256', (string) $endpoint);
|
||||
|
||||
$subscription = PushSubscription::query()
|
||||
->where('event_id', $event->getKey())
|
||||
->where(function ($query) use ($hash, $endpoint) {
|
||||
$query->where('endpoint_hash', $hash)
|
||||
->orWhere('endpoint', $endpoint);
|
||||
})
|
||||
->first();
|
||||
|
||||
if (! $subscription) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$subscription->update([
|
||||
'status' => 'revoked',
|
||||
'last_failed_at' => now(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function markFailed(PushSubscription $subscription, ?string $message = null): void
|
||||
{
|
||||
$subscription->fill([
|
||||
'last_failed_at' => now(),
|
||||
'failure_count' => min(65535, $subscription->failure_count + 1),
|
||||
]);
|
||||
|
||||
if ($subscription->failure_count >= 3) {
|
||||
$subscription->status = 'revoked';
|
||||
}
|
||||
|
||||
$meta = $subscription->meta ?? [];
|
||||
if ($message) {
|
||||
$meta['last_error'] = substr($message, 0, 255);
|
||||
}
|
||||
|
||||
$subscription->meta = $meta;
|
||||
$subscription->save();
|
||||
}
|
||||
|
||||
public function markDelivered(PushSubscription $subscription): void
|
||||
{
|
||||
$subscription->fill([
|
||||
'last_notified_at' => now(),
|
||||
'last_failed_at' => null,
|
||||
'failure_count' => 0,
|
||||
])->save();
|
||||
}
|
||||
|
||||
private function sanitizeIdentifier(?string $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sanitized = preg_replace('/[^A-Za-z0-9 _\-]/', '', $value) ?? '';
|
||||
$sanitized = trim(mb_substr($sanitized, 0, 120));
|
||||
|
||||
return $sanitized === '' ? null : $sanitized;
|
||||
}
|
||||
|
||||
private function normalizeExpiration(mixed $value): ?CarbonImmutable
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
// Push API reports milliseconds
|
||||
$seconds = (int) round(((float) $value) / 1000);
|
||||
|
||||
return CarbonImmutable::createFromTimestampUTC($seconds);
|
||||
}
|
||||
|
||||
try {
|
||||
return CarbonImmutable::parse((string) $value);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"laravel/wayfinder": "^0.1.9",
|
||||
"league/commonmark": "^2.7",
|
||||
"minishlink/web-push": "*",
|
||||
"simplesoftwareio/simple-qrcode": "^4.2",
|
||||
"spatie/laravel-translatable": "^6.11",
|
||||
"staudenmeir/belongs-to-through": "^2.17",
|
||||
|
||||
330
composer.lock
generated
330
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "5409eee4f26e2827449d85cf6b40209d",
|
||||
"content-hash": "0db4b5e72dbe203bba7d50aa2e5d5e89",
|
||||
"packages": [
|
||||
{
|
||||
"name": "anourvalar/eloquent-serialize",
|
||||
@@ -3969,6 +3969,71 @@
|
||||
},
|
||||
"time": "2025-07-25T09:04:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "minishlink/web-push",
|
||||
"version": "v9.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/web-push-libs/web-push-php.git",
|
||||
"reference": "9c9623bf2f455015cb03f21f175cd42345e039a0"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/9c9623bf2f455015cb03f21f175cd42345e039a0",
|
||||
"reference": "9c9623bf2f455015cb03f21f175cd42345e039a0",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-openssl": "*",
|
||||
"guzzlehttp/guzzle": "^7.4.5",
|
||||
"php": ">=8.1",
|
||||
"spomky-labs/base64url": "^2.0.4",
|
||||
"web-token/jwt-library": "^3.3.0|^4.0.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^v3.68.3",
|
||||
"phpstan/phpstan": "^1.10.57",
|
||||
"phpunit/phpunit": "^10.5.9"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-bcmath": "Optional for performance.",
|
||||
"ext-gmp": "Optional for performance."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Minishlink\\WebPush\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Louis Lagrange",
|
||||
"email": "lagrange.louis@gmail.com",
|
||||
"homepage": "https://github.com/Minishlink"
|
||||
}
|
||||
],
|
||||
"description": "Web Push library for PHP",
|
||||
"homepage": "https://github.com/web-push-libs/web-push-php",
|
||||
"keywords": [
|
||||
"Push API",
|
||||
"WebPush",
|
||||
"notifications",
|
||||
"push",
|
||||
"web"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/web-push-libs/web-push-php/issues",
|
||||
"source": "https://github.com/web-push-libs/web-push-php/tree/v9.0.2"
|
||||
},
|
||||
"time": "2025-01-29T17:44:07+00:00"
|
||||
},
|
||||
{
|
||||
"name": "monolog/monolog",
|
||||
"version": "3.9.0",
|
||||
@@ -6448,6 +6513,180 @@
|
||||
],
|
||||
"time": "2025-02-21T14:16:57+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spomky-labs/base64url",
|
||||
"version": "v2.0.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Spomky-Labs/base64url.git",
|
||||
"reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Spomky-Labs/base64url/zipball/7752ce931ec285da4ed1f4c5aa27e45e097be61d",
|
||||
"reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/extension-installer": "^1.0",
|
||||
"phpstan/phpstan": "^0.11|^0.12",
|
||||
"phpstan/phpstan-beberlei-assert": "^0.11|^0.12",
|
||||
"phpstan/phpstan-deprecation-rules": "^0.11|^0.12",
|
||||
"phpstan/phpstan-phpunit": "^0.11|^0.12",
|
||||
"phpstan/phpstan-strict-rules": "^0.11|^0.12"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Base64Url\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Florent Morselli",
|
||||
"homepage": "https://github.com/Spomky-Labs/base64url/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Base 64 URL Safe Encoding/Decoding PHP Library",
|
||||
"homepage": "https://github.com/Spomky-Labs/base64url",
|
||||
"keywords": [
|
||||
"base64",
|
||||
"rfc4648",
|
||||
"safe",
|
||||
"url"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Spomky-Labs/base64url/issues",
|
||||
"source": "https://github.com/Spomky-Labs/base64url/tree/v2.0.4"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/Spomky",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/FlorentMorselli",
|
||||
"type": "patreon"
|
||||
}
|
||||
],
|
||||
"time": "2020-11-03T09:10:25+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spomky-labs/pki-framework",
|
||||
"version": "1.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Spomky-Labs/pki-framework.git",
|
||||
"reference": "bf6f55a9d9eb25b7781640221cb54f5c727850d7"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/bf6f55a9d9eb25b7781640221cb54f5c727850d7",
|
||||
"reference": "bf6f55a9d9eb25b7781640221cb54f5c727850d7",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14",
|
||||
"ext-mbstring": "*",
|
||||
"php": ">=8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"ekino/phpstan-banned-code": "^1.0|^2.0|^3.0",
|
||||
"ext-gmp": "*",
|
||||
"ext-openssl": "*",
|
||||
"infection/infection": "^0.28|^0.29|^0.31",
|
||||
"php-parallel-lint/php-parallel-lint": "^1.3",
|
||||
"phpstan/extension-installer": "^1.3|^2.0",
|
||||
"phpstan/phpstan": "^1.8|^2.0",
|
||||
"phpstan/phpstan-deprecation-rules": "^1.0|^2.0",
|
||||
"phpstan/phpstan-phpunit": "^1.1|^2.0",
|
||||
"phpstan/phpstan-strict-rules": "^1.3|^2.0",
|
||||
"phpunit/phpunit": "^10.1|^11.0|^12.0",
|
||||
"rector/rector": "^1.0|^2.0",
|
||||
"roave/security-advisories": "dev-latest",
|
||||
"symfony/string": "^6.4|^7.0|^8.0",
|
||||
"symfony/var-dumper": "^6.4|^7.0|^8.0",
|
||||
"symplify/easy-coding-standard": "^12.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-bcmath": "For better performance (or GMP)",
|
||||
"ext-gmp": "For better performance (or BCMath)",
|
||||
"ext-openssl": "For OpenSSL based cyphering"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"SpomkyLabs\\Pki\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Joni Eskelinen",
|
||||
"email": "jonieske@gmail.com",
|
||||
"role": "Original developer"
|
||||
},
|
||||
{
|
||||
"name": "Florent Morselli",
|
||||
"email": "florent.morselli@spomky-labs.com",
|
||||
"role": "Spomky-Labs PKI Framework developer"
|
||||
}
|
||||
],
|
||||
"description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.",
|
||||
"homepage": "https://github.com/spomky-labs/pki-framework",
|
||||
"keywords": [
|
||||
"DER",
|
||||
"Private Key",
|
||||
"ac",
|
||||
"algorithm identifier",
|
||||
"asn.1",
|
||||
"asn1",
|
||||
"attribute certificate",
|
||||
"certificate",
|
||||
"certification request",
|
||||
"cryptography",
|
||||
"csr",
|
||||
"decrypt",
|
||||
"ec",
|
||||
"encrypt",
|
||||
"pem",
|
||||
"pkcs",
|
||||
"public key",
|
||||
"rsa",
|
||||
"sign",
|
||||
"signature",
|
||||
"verify",
|
||||
"x.509",
|
||||
"x.690",
|
||||
"x509",
|
||||
"x690"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Spomky-Labs/pki-framework/issues",
|
||||
"source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/Spomky",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/FlorentMorselli",
|
||||
"type": "patreon"
|
||||
}
|
||||
],
|
||||
"time": "2025-10-22T08:24:34+00:00"
|
||||
},
|
||||
{
|
||||
"name": "staudenmeir/belongs-to-through",
|
||||
"version": "v2.17",
|
||||
@@ -9405,6 +9644,95 @@
|
||||
}
|
||||
],
|
||||
"time": "2024-11-21T01:49:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "web-token/jwt-library",
|
||||
"version": "4.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/web-token/jwt-library.git",
|
||||
"reference": "b05d01d4138b1e06328e29075a21a6da974935df"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/web-token/jwt-library/zipball/b05d01d4138b1e06328e29075a21a6da974935df",
|
||||
"reference": "b05d01d4138b1e06328e29075a21a6da974935df",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"brick/math": "^0.12|^0.13|^0.14",
|
||||
"php": ">=8.2",
|
||||
"psr/clock": "^1.0",
|
||||
"spomky-labs/pki-framework": "^1.2.1"
|
||||
},
|
||||
"conflict": {
|
||||
"spomky-labs/jose": "*"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-bcmath": "GMP or BCMath is highly recommended to improve the library performance",
|
||||
"ext-gmp": "GMP or BCMath is highly recommended to improve the library performance",
|
||||
"ext-openssl": "For key management (creation, optimization, etc.) and some algorithms (AES, RSA, ECDSA, etc.)",
|
||||
"ext-sodium": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys",
|
||||
"paragonie/sodium_compat": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys",
|
||||
"spomky-labs/aes-key-wrap": "For all Key Wrapping algorithms (AxxxKW, AxxxGCMKW, PBES2-HSxxx+AyyyKW...)",
|
||||
"symfony/console": "Needed to use console commands",
|
||||
"symfony/http-client": "To enable JKU/X5U support."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Jose\\Component\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Florent Morselli",
|
||||
"homepage": "https://github.com/Spomky"
|
||||
},
|
||||
{
|
||||
"name": "All contributors",
|
||||
"homepage": "https://github.com/web-token/jwt-framework/contributors"
|
||||
}
|
||||
],
|
||||
"description": "JWT library",
|
||||
"homepage": "https://github.com/web-token",
|
||||
"keywords": [
|
||||
"JOSE",
|
||||
"JWE",
|
||||
"JWK",
|
||||
"JWKSet",
|
||||
"JWS",
|
||||
"Jot",
|
||||
"RFC7515",
|
||||
"RFC7516",
|
||||
"RFC7517",
|
||||
"RFC7518",
|
||||
"RFC7519",
|
||||
"RFC7520",
|
||||
"bundle",
|
||||
"jwa",
|
||||
"jwt",
|
||||
"symfony"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/web-token/jwt-library/issues",
|
||||
"source": "https://github.com/web-token/jwt-library/tree/4.1.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/Spomky",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/FlorentMorselli",
|
||||
"type": "patreon"
|
||||
}
|
||||
],
|
||||
"time": "2025-10-22T08:01:38+00:00"
|
||||
}
|
||||
],
|
||||
"packages-dev": [
|
||||
|
||||
7
config/notifications.php
Normal file
7
config/notifications.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'guest_achievements' => [
|
||||
'milestones' => array_map('intval', explode(',', (string) env('GUEST_ACHIEVEMENT_MILESTONES', '10,25,50'))),
|
||||
],
|
||||
];
|
||||
13
config/push.php
Normal file
13
config/push.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'enabled' => (bool) env('PUSH_ENABLED', false),
|
||||
|
||||
'ttl' => (int) env('PUSH_TTL', 600),
|
||||
|
||||
'vapid' => [
|
||||
'public_key' => env('PUSH_VAPID_PUBLIC_KEY'),
|
||||
'private_key' => env('PUSH_VAPID_PRIVATE_KEY'),
|
||||
'subject' => env('PUSH_VAPID_SUBJECT', env('APP_URL', 'mailto:support@example.com')),
|
||||
],
|
||||
];
|
||||
@@ -29,6 +29,11 @@ return [
|
||||
'lock_seconds' => (int) env('STORAGE_QUEUE_HEALTH_LOCK_SECONDS', 120),
|
||||
'cache_minutes' => (int) env('STORAGE_QUEUE_HEALTH_CACHE_MINUTES', 10),
|
||||
'stalled_minutes' => (int) env('STORAGE_QUEUE_STALLED_MINUTES', 10),
|
||||
'pending_event_minutes' => (int) env('STORAGE_QUEUE_PENDING_EVENT_MINUTES', 8),
|
||||
'pending_event_threshold' => (int) env('STORAGE_QUEUE_PENDING_EVENT_THRESHOLD', 5),
|
||||
'failed_event_minutes' => (int) env('STORAGE_QUEUE_FAILED_EVENT_MINUTES', 30),
|
||||
'failed_event_threshold' => (int) env('STORAGE_QUEUE_FAILED_EVENT_THRESHOLD', 2),
|
||||
'guest_alert_ttl' => (int) env('STORAGE_QUEUE_GUEST_ALERT_TTL', 30),
|
||||
'thresholds' => [
|
||||
'default' => [
|
||||
'warning' => (int) env('STORAGE_QUEUE_DEFAULT_WARNING', 100),
|
||||
|
||||
41
database/factories/PushSubscriptionFactory.php
Normal file
41
database/factories/PushSubscriptionFactory.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\PushSubscription;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PushSubscriptionFactory extends Factory
|
||||
{
|
||||
protected $model = PushSubscription::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$endpoint = $this->faker->url();
|
||||
|
||||
return [
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'event_id' => Event::factory(),
|
||||
'guest_identifier' => Str::slug($this->faker->firstName()),
|
||||
'device_id' => (string) Str::uuid(),
|
||||
'endpoint' => $endpoint,
|
||||
'endpoint_hash' => hash('sha256', $endpoint),
|
||||
'public_key' => base64_encode(random_bytes(32)),
|
||||
'auth_token' => base64_encode(random_bytes(16)),
|
||||
'content_encoding' => 'aes128gcm',
|
||||
'status' => 'active',
|
||||
'language' => 'de',
|
||||
'user_agent' => 'Mozilla/5.0',
|
||||
];
|
||||
}
|
||||
|
||||
public function revoked(): static
|
||||
{
|
||||
return $this->state([
|
||||
'status' => 'revoked',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('push_subscriptions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('event_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('guest_identifier', 120)->nullable();
|
||||
$table->string('device_id', 120);
|
||||
$table->string('endpoint', 500)->unique();
|
||||
$table->string('endpoint_hash', 128)->index();
|
||||
$table->string('public_key', 255);
|
||||
$table->string('auth_token', 255);
|
||||
$table->string('content_encoding', 32)->default('aes128gcm');
|
||||
$table->string('status', 32)->default('active');
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->timestamp('last_seen_at')->nullable();
|
||||
$table->timestamp('last_notified_at')->nullable();
|
||||
$table->timestamp('last_failed_at')->nullable();
|
||||
$table->unsignedSmallInteger('failure_count')->default(0);
|
||||
$table->string('language', 12)->nullable();
|
||||
$table->string('user_agent', 255)->nullable();
|
||||
$table->json('meta')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['event_id', 'status']);
|
||||
$table->index(['event_id', 'guest_identifier']);
|
||||
$table->index(['event_id', 'device_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('push_subscriptions');
|
||||
}
|
||||
};
|
||||
@@ -79,6 +79,16 @@ To enable Horizon (dashboard, smart balancing):
|
||||
docker compose --profile horizon up -d horizon
|
||||
```
|
||||
|
||||
## 6. Scheduler & cron jobs
|
||||
|
||||
The compose stack ships a `scheduler` service that runs `php artisan schedule:work`, so all scheduled commands defined in `App\Console\Kernel` stay active. For upload health monitoring, keep the helper script from `cron/upload_queue_health.sh` on the host (or inside a management container) and add a cron entry:
|
||||
|
||||
```
|
||||
*/5 * * * * /var/www/html/cron/upload_queue_health.sh
|
||||
```
|
||||
|
||||
This wrapper logs to `storage/logs/cron-upload-queue-health.log` and executes `php artisan storage:check-upload-queues`, which in turn issues guest-facing upload alerts when queues stall or fail repeatedly. In containerised environments mount the repository so the script can reuse the same PHP binary as the app, or call the artisan command directly via `docker compose exec app php artisan storage:check-upload-queues`.
|
||||
|
||||
The dashboard becomes available at `/horizon` and is protected by the Filament super-admin auth guard.
|
||||
|
||||
## 6. Persistent data & volumes
|
||||
|
||||
68
docs/ops/guest-notification-ops.md
Normal file
68
docs/ops/guest-notification-ops.md
Normal file
@@ -0,0 +1,68 @@
|
||||
## Guest Notification & Push Ops Guide
|
||||
|
||||
This runbook explains how to keep the guest notification centre healthy, roll out web push, and operate the new upload health alerts.
|
||||
|
||||
### 1. Database & config prerequisites
|
||||
|
||||
1. Run the latest migrations so the `push_subscriptions` table exists:
|
||||
```bash
|
||||
php artisan migrate --force
|
||||
```
|
||||
2. Generate VAPID keys (using `web-push` or any Web Push helper) and store them in the environment:
|
||||
```
|
||||
PUSH_ENABLED=true
|
||||
PUSH_VAPID_PUBLIC_KEY=<base64-url-key>
|
||||
PUSH_VAPID_PRIVATE_KEY=<base64-url-key>
|
||||
PUSH_VAPID_SUBJECT="mailto:ops@example.com"
|
||||
```
|
||||
3. Redeploy the guest PWA (Vite build) so the runtime config exposes the new keys to the service worker.
|
||||
|
||||
### 2. Queue workers
|
||||
|
||||
Push deliveries are dispatched on the dedicated `notifications` queue. Ensure one of the queue workers listens to it:
|
||||
|
||||
```bash
|
||||
docs/queue-supervisor/queue-worker.sh default,notifications
|
||||
```
|
||||
|
||||
If Horizon is in use just add `notifications` to the list of queues for at least one supervisor. Monitor `storage/logs/notifications.log` (channel `notifications`) for transport failures.
|
||||
|
||||
### 3. Upload health alerts
|
||||
|
||||
The `storage:check-upload-queues` command now emits guest-facing alerts when uploads stall or fail repeatedly. Schedule it every 5 minutes via cron (see `cron/upload_queue_health.sh`) or the Laravel scheduler:
|
||||
|
||||
```
|
||||
*/5 * * * * /var/www/html/cron/upload_queue_health.sh
|
||||
```
|
||||
|
||||
Tune thresholds with the `STORAGE_QUEUE_*` variables in `.env` (see `.env.example` for defaults). When an alert fires, the tenant admin toolkit also surfaces the same issues.
|
||||
|
||||
### 4. Manual API interactions
|
||||
|
||||
- Register push subscription (from browser dev-tools):
|
||||
```
|
||||
POST /api/v1/events/{token}/push-subscriptions
|
||||
Headers: X-Device-Id
|
||||
Body: { endpoint, keys:{p256dh, auth}, content_encoding }
|
||||
```
|
||||
- Revoke subscription:
|
||||
```
|
||||
DELETE /api/v1/events/{token}/push-subscriptions
|
||||
Body: { endpoint }
|
||||
```
|
||||
- Inspect per-guest state:
|
||||
```bash
|
||||
php artisan tinker
|
||||
>>> App\Models\PushSubscription::where('event_id', 123)->get();
|
||||
```
|
||||
|
||||
### 5. Smoke tests
|
||||
|
||||
After enabling push:
|
||||
|
||||
1. Join a published event, open the notification centre, and enable push (browser prompt must appear).
|
||||
2. Trigger a host broadcast or upload-queue alert; confirm the browser shows a native notification and that the notification drawer refreshes without polling.
|
||||
3. Temporarily stop the upload workers to create ≥5 pending assets; re-run `storage:check-upload-queues` and verify guests receive the “Uploads werden noch verarbeitet …” message.
|
||||
|
||||
Document any deviations in `docs/changes/` for future regressions.
|
||||
|
||||
@@ -41,6 +41,8 @@ Core Features
|
||||
- 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.
|
||||
- When push is available (VAPID keys configured) the drawer surfaces a push toggle, persists subscriptions via `/push-subscriptions`, and the service worker refreshes notifications after every push message.
|
||||
- Operations playbook: see `docs/ops/guest-notification-ops.md` for enabling push, required queues, and cron health checks.
|
||||
- Privacy & legal
|
||||
- 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.
|
||||
@@ -103,6 +105,8 @@ API Touchpoints
|
||||
- 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.
|
||||
- POST `/api/v1/events/{token}/push-subscriptions` — register a browser push subscription (requires `X-Device-Id` + VAPID public key).
|
||||
- DELETE `/api/v1/events/{token}/push-subscriptions` — revoke a stored push subscription by endpoint.
|
||||
|
||||
Limits (MVP defaults)
|
||||
- Max uploads per device per event: 50
|
||||
|
||||
@@ -62,6 +62,8 @@ services:
|
||||
|
||||
Scale workers by increasing `deploy.replicas` (Swarm) or adding `scale` counts (Compose v2).
|
||||
|
||||
> **Heads-up:** Guest push notifications are dispatched on the `notifications` queue. Either add that queue to the default worker (`queue-worker.sh default,notifications`) or create a dedicated worker so push jobs are consumed even when other queues are busy.
|
||||
|
||||
### 3. Optional: Horizon container
|
||||
|
||||
If you prefer Horizon’s dashboard and auto-balancing, add another service:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Minimal service worker for Guest PWA queue sync
|
||||
self.addEventListener('install', (event) => {
|
||||
self.addEventListener('install', () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
@@ -7,63 +6,61 @@ self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
const ASSETS_CACHE = 'guest-assets-v1';
|
||||
const IMAGES_CACHE = 'guest-images-v1';
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const req = event.request;
|
||||
if (req.method !== 'GET') return;
|
||||
|
||||
const url = new URL(req.url);
|
||||
// Only handle same-origin requests
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// Never cache API calls; let them hit network directly
|
||||
if (url.pathname.startsWith('/api/')) return;
|
||||
|
||||
// Cache-first for images
|
||||
if (req.destination === 'image' || /\.(png|jpg|jpeg|webp|avif|gif|svg)(\?.*)?$/i.test(url.pathname)) {
|
||||
event.respondWith((async () => {
|
||||
const cache = await caches.open(IMAGES_CACHE);
|
||||
const cached = await cache.match(req);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const res = await fetch(req, { credentials: 'same-origin' });
|
||||
if (res.ok) cache.put(req, res.clone());
|
||||
return res;
|
||||
} catch (e) {
|
||||
return cached || Response.error();
|
||||
}
|
||||
})());
|
||||
return;
|
||||
}
|
||||
|
||||
// Stale-while-revalidate for CSS/JS assets
|
||||
if (req.destination === 'style' || req.destination === 'script') {
|
||||
event.respondWith((async () => {
|
||||
const cache = await caches.open(ASSETS_CACHE);
|
||||
const cached = await cache.match(req);
|
||||
const networkPromise = fetch(req, { credentials: 'same-origin' })
|
||||
.then((res) => {
|
||||
if (res.ok) cache.put(req, res.clone());
|
||||
return res;
|
||||
})
|
||||
.catch(() => null);
|
||||
return cached || (await networkPromise) || Response.error();
|
||||
})());
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener('sync', (event) => {
|
||||
if (event.tag === 'upload-queue') {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: 'window' });
|
||||
for (const client of clients) {
|
||||
client.postMessage({ type: 'sync-queue' });
|
||||
}
|
||||
clients.forEach((client) => client.postMessage({ type: 'sync-queue' }));
|
||||
})()
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
const payload = event.data?.json?.() ?? {};
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const title = payload.title ?? 'Neue Nachricht';
|
||||
const options = {
|
||||
body: payload.body ?? '',
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/badge.png',
|
||||
data: payload.data ?? {},
|
||||
};
|
||||
|
||||
await self.registration.showNotification(title, options);
|
||||
|
||||
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
clients.forEach((client) => client.postMessage({ type: 'guest-notification-refresh' }));
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
const targetUrl = event.notification.data?.url || '/';
|
||||
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||
for (const client of clientList) {
|
||||
if ('focus' in client) {
|
||||
client.navigate(targetUrl);
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
if (self.clients.openWindow) {
|
||||
return self.clients.openWindow(targetUrl);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('pushsubscriptionchange', (event) => {
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||
clientList.forEach((client) => client.postMessage({ type: 'push-subscription-change' }));
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
||||
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext';
|
||||
import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext';
|
||||
import { useGuestTaskProgress, TASK_BADGE_TARGET } from '../hooks/useGuestTaskProgress';
|
||||
import { usePushSubscription } from '../hooks/usePushSubscription';
|
||||
|
||||
const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
heart: Heart,
|
||||
@@ -224,7 +225,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{notificationCenter && (
|
||||
{notificationCenter && eventToken && (
|
||||
<NotificationButton
|
||||
eventToken={eventToken}
|
||||
center={notificationCenter}
|
||||
@@ -254,12 +255,15 @@ type NotificationButtonProps = {
|
||||
t: TranslateFn;
|
||||
};
|
||||
|
||||
type PushState = ReturnType<typeof usePushSubscription>;
|
||||
|
||||
function NotificationButton({ center, eventToken, open, onToggle, panelRef, checklistItems, taskProgress, t }: NotificationButtonProps) {
|
||||
const badgeCount = center.totalCount;
|
||||
const progressRatio = taskProgress
|
||||
? Math.min(1, taskProgress.completedCount / TASK_BADGE_TARGET)
|
||||
: 0;
|
||||
const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'status'>(center.unreadCount > 0 ? 'unread' : 'all');
|
||||
const pushState = usePushSubscription(eventToken);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -338,6 +342,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, chec
|
||||
<NotificationStatusBar
|
||||
lastFetchedAt={center.lastFetchedAt}
|
||||
isOffline={center.isOffline}
|
||||
push={pushState}
|
||||
t={t}
|
||||
/>
|
||||
<div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||
@@ -528,7 +533,6 @@ function NotificationCta({ cta, onFollow }: { cta: { label?: string; href?: stri
|
||||
|
||||
return (
|
||||
<a
|
||||
href={cta.href}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -623,11 +627,40 @@ function NotificationTabs({
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationStatusBar({ lastFetchedAt, isOffline, t }: { lastFetchedAt: Date | null; isOffline: boolean; t: TranslateFn }) {
|
||||
function NotificationStatusBar({
|
||||
lastFetchedAt,
|
||||
isOffline,
|
||||
push,
|
||||
t,
|
||||
}: {
|
||||
lastFetchedAt: Date | null;
|
||||
isOffline: boolean;
|
||||
push: PushState;
|
||||
t: TranslateFn;
|
||||
}) {
|
||||
const label = lastFetchedAt ? formatRelativeTime(lastFetchedAt.toISOString()) : t('header.notifications.never', 'Noch keine Aktualisierung');
|
||||
const pushDescription = React.useMemo(() => {
|
||||
if (!push.supported) {
|
||||
return t('header.notifications.pushUnsupported', 'Push wird nicht unterstützt');
|
||||
}
|
||||
if (push.permission === 'denied') {
|
||||
return t('header.notifications.pushDenied', 'Browser blockiert Benachrichtigungen');
|
||||
}
|
||||
if (push.subscribed) {
|
||||
return t('header.notifications.pushActive', 'Push aktiv');
|
||||
}
|
||||
return t('header.notifications.pushInactive', 'Push deaktiviert');
|
||||
}, [push.permission, push.subscribed, push.supported, t]);
|
||||
|
||||
const buttonLabel = push.subscribed
|
||||
? t('header.notifications.pushDisable', 'Deaktivieren')
|
||||
: t('header.notifications.pushEnable', 'Aktivieren');
|
||||
|
||||
const pushButtonDisabled = push.loading || !push.supported || push.permission === 'denied';
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex items-center justify-between text-[11px] text-slate-500">
|
||||
<div className="mt-2 space-y-2 text-[11px] text-slate-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>
|
||||
{t('header.notifications.lastSync', 'Zuletzt aktualisiert')}: {label}
|
||||
</span>
|
||||
@@ -638,5 +671,25 @@ function NotificationStatusBar({ lastFetchedAt, isOffline, t }: { lastFetchedAt:
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 rounded-full bg-slate-100/80 px-3 py-1 text-[11px] font-semibold text-slate-600">
|
||||
<div className="flex items-center gap-1">
|
||||
<Bell className="h-3.5 w-3.5" aria-hidden />
|
||||
<span>{pushDescription}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (push.subscribed ? push.disable() : push.enable())}
|
||||
disabled={pushButtonDisabled}
|
||||
className="rounded-full bg-white/80 px-3 py-0.5 text-[11px] font-semibold text-pink-600 shadow disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{push.loading ? t('header.notifications.pushLoading', '…') : buttonLabel}
|
||||
</button>
|
||||
</div>
|
||||
{push.error && (
|
||||
<p className="text-[11px] font-semibold text-rose-600">
|
||||
{push.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,6 +125,20 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handler = (event: MessageEvent) => {
|
||||
if (event.data?.type === 'guest-notification-refresh') {
|
||||
void loadNotifications({ silent: true });
|
||||
}
|
||||
};
|
||||
|
||||
navigator.serviceWorker?.addEventListener('message', handler);
|
||||
|
||||
return () => {
|
||||
navigator.serviceWorker?.removeEventListener('message', handler);
|
||||
};
|
||||
}, [loadNotifications]);
|
||||
|
||||
const markAsRead = React.useCallback(
|
||||
async (id: number) => {
|
||||
if (!eventToken) {
|
||||
|
||||
168
resources/js/guest/hooks/usePushSubscription.ts
Normal file
168
resources/js/guest/hooks/usePushSubscription.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import React from 'react';
|
||||
import { getPushConfig } from '../lib/runtime-config';
|
||||
import { registerPushSubscription, unregisterPushSubscription } from '../services/pushApi';
|
||||
|
||||
type PushSubscriptionState = {
|
||||
supported: boolean;
|
||||
permission: NotificationPermission;
|
||||
subscribed: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
enable: () => Promise<void>;
|
||||
disable: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function usePushSubscription(eventToken?: string): PushSubscriptionState {
|
||||
const pushConfig = React.useMemo(() => getPushConfig(), []);
|
||||
const supported = React.useMemo(() => {
|
||||
return typeof window !== 'undefined'
|
||||
&& typeof navigator !== 'undefined'
|
||||
&& typeof Notification !== 'undefined'
|
||||
&& 'serviceWorker' in navigator
|
||||
&& 'PushManager' in window
|
||||
&& pushConfig.enabled;
|
||||
}, [pushConfig.enabled]);
|
||||
|
||||
const [permission, setPermission] = React.useState<NotificationPermission>(() => {
|
||||
if (typeof Notification === 'undefined') {
|
||||
return 'default';
|
||||
}
|
||||
|
||||
return Notification.permission;
|
||||
});
|
||||
const [subscription, setSubscription] = React.useState<PushSubscription | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
if (!supported || !eventToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const current = await registration.pushManager.getSubscription();
|
||||
setSubscription(current);
|
||||
} catch (err) {
|
||||
console.warn('Unable to refresh push subscription', err);
|
||||
setSubscription(null);
|
||||
}
|
||||
}, [eventToken, supported]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!supported) {
|
||||
return;
|
||||
}
|
||||
|
||||
void refresh();
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.data?.type === 'push-subscription-change') {
|
||||
void refresh();
|
||||
}
|
||||
};
|
||||
|
||||
navigator.serviceWorker?.addEventListener('message', handleMessage);
|
||||
|
||||
return () => {
|
||||
navigator.serviceWorker?.removeEventListener('message', handleMessage);
|
||||
};
|
||||
}, [refresh, supported]);
|
||||
|
||||
const enable = React.useCallback(async () => {
|
||||
if (!supported || !eventToken) {
|
||||
setError('Push-Benachrichtigungen werden auf diesem Gerät nicht unterstützt.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const permissionResult = await Notification.requestPermission();
|
||||
setPermission(permissionResult);
|
||||
|
||||
if (permissionResult !== 'granted') {
|
||||
throw new Error('Bitte erlaube Benachrichtigungen, um Push zu aktivieren.');
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const existing = await registration.pushManager.getSubscription();
|
||||
|
||||
if (existing) {
|
||||
await registerPushSubscription(eventToken, existing);
|
||||
setSubscription(existing);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pushConfig.vapidPublicKey) {
|
||||
throw new Error('Push-Konfiguration ist nicht vollständig.');
|
||||
}
|
||||
|
||||
const newSubscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(pushConfig.vapidPublicKey),
|
||||
});
|
||||
|
||||
await registerPushSubscription(eventToken, newSubscription);
|
||||
setSubscription(newSubscription);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Push konnte nicht aktiviert werden.';
|
||||
setError(message);
|
||||
console.error(err);
|
||||
await refresh();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [eventToken, pushConfig.vapidPublicKey, refresh, supported]);
|
||||
|
||||
const disable = React.useCallback(async () => {
|
||||
if (!supported || !eventToken || !subscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await unregisterPushSubscription(eventToken, subscription.endpoint);
|
||||
await subscription.unsubscribe();
|
||||
setSubscription(null);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Push konnte nicht deaktiviert werden.';
|
||||
setError(message);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [eventToken, subscription, supported]);
|
||||
|
||||
return {
|
||||
supported,
|
||||
permission,
|
||||
subscribed: Boolean(subscription),
|
||||
loading,
|
||||
error,
|
||||
enable,
|
||||
disable,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const rawData = typeof window !== 'undefined'
|
||||
? window.atob(base64)
|
||||
: Buffer.from(base64, 'base64').toString('binary');
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; i += 1) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
|
||||
return outputArray;
|
||||
}
|
||||
24
resources/js/guest/lib/runtime-config.ts
Normal file
24
resources/js/guest/lib/runtime-config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
type PushConfig = {
|
||||
enabled: boolean;
|
||||
vapidPublicKey: string | null;
|
||||
};
|
||||
|
||||
type RuntimeConfig = {
|
||||
push: PushConfig;
|
||||
};
|
||||
|
||||
export function getRuntimeConfig(): RuntimeConfig {
|
||||
const raw = typeof window !== 'undefined' ? window.__GUEST_RUNTIME_CONFIG__ : undefined;
|
||||
|
||||
return {
|
||||
push: {
|
||||
enabled: Boolean(raw?.push?.enabled),
|
||||
vapidPublicKey: raw?.push?.vapidPublicKey ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getPushConfig(): PushConfig {
|
||||
return getRuntimeConfig().push;
|
||||
}
|
||||
|
||||
71
resources/js/guest/services/pushApi.ts
Normal file
71
resources/js/guest/services/pushApi.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { getDeviceId } from '../lib/device';
|
||||
|
||||
type PushSubscriptionPayload = {
|
||||
endpoint: string;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
expirationTime?: number | null;
|
||||
contentEncoding?: string | null;
|
||||
};
|
||||
|
||||
function buildHeaders(): HeadersInit {
|
||||
return {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Device-Id': getDeviceId(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerPushSubscription(eventToken: string, subscription: PushSubscription): Promise<void> {
|
||||
const json = subscription.toJSON() as PushSubscriptionPayload;
|
||||
|
||||
const body = {
|
||||
endpoint: json.endpoint,
|
||||
keys: json.keys,
|
||||
expiration_time: json.expirationTime ?? null,
|
||||
content_encoding: json.contentEncoding ?? null,
|
||||
};
|
||||
|
||||
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/push-subscriptions`, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(),
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await parseError(response);
|
||||
throw new Error(message ?? 'Push-Registrierung fehlgeschlagen.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function unregisterPushSubscription(eventToken: string, endpoint: string): Promise<void> {
|
||||
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/push-subscriptions`, {
|
||||
method: 'DELETE',
|
||||
headers: buildHeaders(),
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ endpoint }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await parseError(response);
|
||||
throw new Error(message ?? 'Push konnte nicht deaktiviert werden.');
|
||||
}
|
||||
}
|
||||
|
||||
async function parseError(response: Response): Promise<string | null> {
|
||||
try {
|
||||
const payload = await response.clone().json();
|
||||
const errorMessage = payload?.error?.message ?? payload?.message;
|
||||
if (typeof errorMessage === 'string' && errorMessage.trim() !== '') {
|
||||
return errorMessage;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse push API error', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
13
resources/js/guest/types/global.d.ts
vendored
Normal file
13
resources/js/guest/types/global.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__GUEST_RUNTIME_CONFIG__?: {
|
||||
push?: {
|
||||
enabled?: boolean;
|
||||
vapidPublicKey?: string | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,14 @@
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
@viteReactRefresh
|
||||
@vite(['resources/css/app.css', 'resources/js/guest/main.tsx'])
|
||||
<script>
|
||||
window.__GUEST_RUNTIME_CONFIG__ = @json([
|
||||
'push' => [
|
||||
'enabled' => config('push.enabled', false),
|
||||
'vapidPublicKey' => config('push.vapid.public_key'),
|
||||
],
|
||||
]);
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -75,6 +75,10 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
Route::post('/events/{token}/notifications/{notification}/dismiss', [EventPublicController::class, 'dismissNotification'])
|
||||
->whereNumber('notification')
|
||||
->name('events.notifications.dismiss');
|
||||
Route::post('/events/{token}/push-subscriptions', [EventPublicController::class, 'registerPushSubscription'])
|
||||
->name('events.push-subscriptions.store');
|
||||
Route::delete('/events/{token}/push-subscriptions', [EventPublicController::class, 'destroyPushSubscription'])
|
||||
->name('events.push-subscriptions.destroy');
|
||||
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}/tasks', [EventPublicController::class, 'tasks'])->name('events.tasks');
|
||||
|
||||
66
tests/Feature/Api/Event/PushSubscriptionApiTest.php
Normal file
66
tests/Feature/Api/Event/PushSubscriptionApiTest.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api\Event;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\PushSubscription;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PushSubscriptionApiTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_guest_can_register_push_subscription(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
$event = Event::factory()->for($tenant)->create(['status' => 'published']);
|
||||
$token = app(EventJoinTokenService::class)->createToken($event)->plain_token;
|
||||
|
||||
$payload = [
|
||||
'endpoint' => 'https://updates.example.com/push/abc',
|
||||
'keys' => [
|
||||
'p256dh' => base64_encode('key'),
|
||||
'auth' => base64_encode('auth'),
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->withHeaders(['X-Device-Id' => 'device-test-1'])
|
||||
->postJson("/api/v1/events/{$token}/push-subscriptions", $payload);
|
||||
|
||||
$response->assertCreated();
|
||||
$this->assertDatabaseHas('push_subscriptions', [
|
||||
'event_id' => $event->id,
|
||||
'device_id' => 'device-test-1',
|
||||
'endpoint' => $payload['endpoint'],
|
||||
'status' => 'active',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_guest_can_revoke_push_subscription(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
$event = Event::factory()->for($tenant)->create(['status' => 'published']);
|
||||
$token = app(EventJoinTokenService::class)->createToken($event)->plain_token;
|
||||
|
||||
$subscription = PushSubscription::factory()
|
||||
->for($tenant)
|
||||
->for($event)
|
||||
->create([
|
||||
'endpoint' => 'https://updates.example.com/push/cached',
|
||||
'device_id' => 'device-revoke',
|
||||
]);
|
||||
|
||||
$response = $this->deleteJson("/api/v1/events/{$token}/push-subscriptions", [
|
||||
'endpoint' => $subscription->endpoint,
|
||||
]);
|
||||
|
||||
$response->assertOk()->assertJsonPath('status', 'revoked');
|
||||
$this->assertDatabaseHas('push_subscriptions', [
|
||||
'id' => $subscription->id,
|
||||
'status' => 'revoked',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
namespace Tests\Feature\Console;
|
||||
|
||||
use App\Enums\GuestNotificationType;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventMediaAsset;
|
||||
use App\Models\MediaStorageTarget;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Queue\QueueManager;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
@@ -24,6 +26,10 @@ class CheckUploadQueuesCommandTest extends TestCase
|
||||
]);
|
||||
config()->set('storage-monitor.queue_health.stalled_minutes', 5);
|
||||
config()->set('storage-monitor.queue_health.cache_minutes', 5);
|
||||
config()->set('storage-monitor.queue_health.pending_event_threshold', 1);
|
||||
config()->set('storage-monitor.queue_health.pending_event_minutes', 5);
|
||||
config()->set('storage-monitor.queue_health.failed_event_threshold', 1);
|
||||
config()->set('storage-monitor.queue_health.failed_event_minutes', 5);
|
||||
|
||||
$manager = Mockery::mock(QueueManager::class);
|
||||
$connection = Mockery::mock(\Illuminate\Contracts\Queue\Queue::class);
|
||||
@@ -51,7 +57,8 @@ class CheckUploadQueuesCommandTest extends TestCase
|
||||
'priority' => 100,
|
||||
]);
|
||||
|
||||
$event = Event::factory()->create();
|
||||
$tenant = Tenant::factory()->create();
|
||||
$event = Event::factory()->for($tenant)->create(['status' => 'published']);
|
||||
$asset = EventMediaAsset::create([
|
||||
'event_id' => $event->id,
|
||||
'media_storage_target_id' => $target->id,
|
||||
@@ -66,6 +73,16 @@ class CheckUploadQueuesCommandTest extends TestCase
|
||||
'updated_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
EventMediaAsset::create([
|
||||
'event_id' => $event->id,
|
||||
'media_storage_target_id' => $target->id,
|
||||
'variant' => 'original',
|
||||
'disk' => 'local-hot',
|
||||
'path' => 'events/'.$event->id.'/failed.jpg',
|
||||
'size_bytes' => 256,
|
||||
'status' => 'failed',
|
||||
]);
|
||||
|
||||
$this->artisan('storage:check-upload-queues')
|
||||
->expectsOutput('Checked 1 queue(s); 3 alert(s).')
|
||||
->assertExitCode(0);
|
||||
@@ -74,5 +91,15 @@ class CheckUploadQueuesCommandTest extends TestCase
|
||||
$this->assertNotNull($snapshot);
|
||||
$this->assertSame('critical', $snapshot['queues'][0]['severity']);
|
||||
$this->assertGreaterThanOrEqual(1, count($snapshot['alerts']));
|
||||
|
||||
$this->assertDatabaseHas('guest_notifications', [
|
||||
'event_id' => $event->id,
|
||||
'type' => GuestNotificationType::UPLOAD_ALERT->value,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('guest_notifications', [
|
||||
'event_id' => $event->id,
|
||||
'type' => GuestNotificationType::SUPPORT_TIP->value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
100
tests/Feature/Jobs/SendGuestPushNotificationBatchTest.php
Normal file
100
tests/Feature/Jobs/SendGuestPushNotificationBatchTest.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Jobs;
|
||||
|
||||
use App\Jobs\SendGuestPushNotificationBatch;
|
||||
use App\Models\Event;
|
||||
use App\Models\GuestNotification;
|
||||
use App\Models\PushSubscription;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Push\WebPushDispatcher;
|
||||
use App\Services\PushSubscriptionService;
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Minishlink\WebPush\MessageSentReport;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SendGuestPushNotificationBatchTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_job_marks_subscription_as_delivered(): void
|
||||
{
|
||||
config()->set('push.enabled', true);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$event = Event::factory()->for($tenant)->create(['status' => 'published']);
|
||||
$notification = GuestNotification::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_id' => $event->id,
|
||||
]);
|
||||
|
||||
$subscription = PushSubscription::factory()
|
||||
->for($tenant)
|
||||
->for($event)
|
||||
->create();
|
||||
|
||||
$report = new MessageSentReport(
|
||||
new Request('POST', 'https://push.example.com'),
|
||||
new Response(201),
|
||||
true,
|
||||
'OK'
|
||||
);
|
||||
|
||||
$dispatcher = new class($report) extends WebPushDispatcher
|
||||
{
|
||||
public function __construct(private MessageSentReport $report) {}
|
||||
|
||||
public function send(PushSubscription $subscription, array $payload): ?MessageSentReport
|
||||
{
|
||||
return $this->report;
|
||||
}
|
||||
};
|
||||
|
||||
$job = new SendGuestPushNotificationBatch($notification->id, [$subscription->id]);
|
||||
$job->handle($dispatcher, app(PushSubscriptionService::class));
|
||||
|
||||
$this->assertNotNull($subscription->fresh()->last_notified_at);
|
||||
$this->assertSame('active', $subscription->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_job_revokes_expired_subscription(): void
|
||||
{
|
||||
config()->set('push.enabled', true);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$event = Event::factory()->for($tenant)->create(['status' => 'published']);
|
||||
$notification = GuestNotification::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_id' => $event->id,
|
||||
]);
|
||||
|
||||
$subscription = PushSubscription::factory()
|
||||
->for($tenant)
|
||||
->for($event)
|
||||
->create();
|
||||
|
||||
$report = new MessageSentReport(
|
||||
new Request('POST', 'https://push.example.com'),
|
||||
new Response(410),
|
||||
false,
|
||||
'Gone'
|
||||
);
|
||||
|
||||
$dispatcher = new class($report) extends WebPushDispatcher
|
||||
{
|
||||
public function __construct(private MessageSentReport $report) {}
|
||||
|
||||
public function send(PushSubscription $subscription, array $payload): ?MessageSentReport
|
||||
{
|
||||
return $this->report;
|
||||
}
|
||||
};
|
||||
|
||||
$job = new SendGuestPushNotificationBatch($notification->id, [$subscription->id]);
|
||||
$job->handle($dispatcher, app(PushSubscriptionService::class));
|
||||
|
||||
$this->assertSame('revoked', $subscription->fresh()->status);
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,9 @@
|
||||
"resources/js/routes/**/*.tsx",
|
||||
"resources/js/types/**/*.ts",
|
||||
"resources/js/lib/**/*.ts",
|
||||
"resources/js/lib/**/*.tsx"
|
||||
"resources/js/lib/**/*.tsx",
|
||||
"resources/js/guest/**/*.ts",
|
||||
"resources/js/guest/**/*.tsx",
|
||||
"resources/js/guest/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user