Add guest push notifications and queue alerts

This commit is contained in:
Codex Agent
2025-11-12 20:38:49 +01:00
parent 2c412e3764
commit 574aa47ce7
34 changed files with 1806 additions and 74 deletions

View File

@@ -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 {

View File

@@ -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();

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

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

View File

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

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

View File

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

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

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