Add guest push notifications and queue alerts
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user