feat: automate guest notification triggers

This commit is contained in:
Codex Agent
2025-11-12 18:46:00 +01:00
parent 4495ac1895
commit 642541c8fb
9 changed files with 416 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Console\Commands;
use App\Enums\GuestNotificationAudience;
use App\Enums\GuestNotificationType;
use App\Models\Event;
use App\Services\GuestNotificationService;
use Illuminate\Console\Command;
class SendGuestFeedbackReminders extends Command
{
protected $signature = 'guest:feedback-reminders {--hours=4 : Minimum hours after event date before reminding} {--limit=50 : Maximum events to process}';
protected $description = 'Send feedback reminder notifications to guests of recently finished events';
public function __construct(private readonly GuestNotificationService $notifications)
{
parent::__construct();
}
public function handle(): int
{
$hours = max(0, (int) $this->option('hours'));
$limit = max(1, (int) $this->option('limit'));
$cutoff = now()->subHours($hours);
$events = Event::query()
->where('status', 'published')
->where('is_active', true)
->whereNotNull('date')
->where('date', '<=', $cutoff)
->whereDoesntHave('guestNotifications', function ($query) {
$query->where('type', GuestNotificationType::FEEDBACK_REQUEST->value);
})
->orderBy('date')
->limit($limit)
->get();
$count = 0;
foreach ($events as $event) {
$title = 'Danke fürs Mitmachen wie war dein Erlebnis?';
$body = 'Teile dein kurzes Feedback mit dem Gastgeber oder lade deine Lieblingsmomente noch einmal hoch.';
$this->notifications->createNotification(
$event,
GuestNotificationType::FEEDBACK_REQUEST,
$title,
$body,
[
'audience_scope' => GuestNotificationAudience::ALL,
'expires_at' => now()->addHours(12),
]
);
$count++;
}
$this->info(sprintf('Feedback reminders dispatched for %d event(s).', $count));
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Events;
use App\Models\Event;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class GuestPhotoUploaded
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
public function __construct(
public Event $event,
public int $photoId,
public string $guestIdentifier,
public ?string $guestName = null,
) {}
}

View File

@@ -5,6 +5,8 @@ namespace App\Http\Controllers\Api;
use App\Enums\GuestNotificationAudience;
use App\Enums\GuestNotificationDeliveryStatus;
use App\Enums\GuestNotificationState;
use App\Enums\GuestNotificationType;
use App\Events\GuestPhotoUploaded;
use App\Models\Event;
use App\Models\EventJoinToken;
use App\Models\EventMediaAsset;
@@ -1765,6 +1767,62 @@ class EventPublicController extends BaseController
];
}
private function notifyDeviceUploadLimit(Event $event, string $guestIdentifier, string $token): void
{
$title = 'Upload-Limit erreicht';
$body = 'Du hast bereits 50 Fotos hochgeladen. Wir speichern deine Warteschlange bitte lösche zuerst alte Uploads.';
$this->guestNotificationService->createNotification(
$event,
GuestNotificationType::UPLOAD_ALERT,
$title,
$body,
[
'audience_scope' => GuestNotificationAudience::GUEST,
'target_identifier' => $guestIdentifier,
'payload' => [
'cta' => [
'label' => 'Warteschlange öffnen',
'href' => sprintf('/e/%s/queue', urlencode($token)),
],
],
'expires_at' => now()->addHours(6),
]
);
}
private function notifyPhotoLimitReached(Event $event): void
{
$title = 'Uploads pausiert Event-Limit erreicht';
if ($this->eventHasActiveNotification($event, GuestNotificationType::UPLOAD_ALERT, $title)) {
return;
}
$body = 'Es können aktuell keine neuen Fotos hochgeladen werden. Wir informieren den Host bleib kurz dran!';
$this->guestNotificationService->createNotification(
$event,
GuestNotificationType::UPLOAD_ALERT,
$title,
$body,
[
'audience_scope' => GuestNotificationAudience::ALL,
'expires_at' => now()->addHours(3),
]
);
}
private function eventHasActiveNotification(Event $event, GuestNotificationType $type, string $title): bool
{
return GuestNotification::query()
->where('event_id', $event->id)
->where('type', $type->value)
->where('title', $title)
->where('status', GuestNotificationState::ACTIVE->value)
->exists();
}
private function resolveNotificationIdentifier(Request $request): string
{
$identifier = $this->determineGuestIdentifier($request);
@@ -2142,6 +2200,10 @@ class EventPublicController extends BaseController
$violation = $this->packageLimitEvaluator->assessPhotoUpload($tenantModel, $eventId, $eventModel);
if ($violation !== null) {
if (($violation['code'] ?? null) === 'photo_limit_exceeded') {
$this->notifyPhotoLimitReached($eventModel);
}
return ApiError::response(
$violation['code'],
$violation['title'],
@@ -2160,6 +2222,8 @@ class EventPublicController extends BaseController
// Per-device cap per event (MVP: 50)
$deviceCount = DB::table('photos')->where('event_id', $eventId)->where('guest_name', $deviceId)->count();
if ($deviceCount >= 50) {
$this->notifyDeviceUploadLimit($eventModel, $deviceId, $token);
$this->recordTokenEvent(
$joinToken,
$request,
@@ -2282,6 +2346,13 @@ class EventPublicController extends BaseController
Response::HTTP_CREATED
);
event(new GuestPhotoUploaded(
$eventModel,
$photoId,
$deviceId,
$validated['guest_name'] ?? null,
));
return $response;
}

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Listeners\GuestNotifications;
use App\Enums\GuestNotificationAudience;
use App\Enums\GuestNotificationType;
use App\Events\GuestPhotoUploaded;
use App\Models\Photo;
use App\Services\GuestNotificationService;
class SendPhotoUploadedNotification
{
/**
* @param int[] $milestones
*/
public function __construct(
private readonly GuestNotificationService $notifications,
private readonly array $milestones = [5, 10, 20]
) {}
public function handle(GuestPhotoUploaded $event): void
{
$guestLabel = $this->resolveGuestLabel($event->guestName, $event->guestIdentifier);
$title = $guestLabel
? sprintf('%s hat gerade ein Foto gemacht 🎉', $guestLabel)
: 'Es gibt neue Fotos!';
$this->notifications->createNotification(
$event->event,
GuestNotificationType::PHOTO_ACTIVITY,
$title,
null,
[
'audience_scope' => GuestNotificationAudience::ALL,
'payload' => [
'photo_id' => $event->photoId,
],
'expires_at' => now()->addHours(3),
]
);
$this->maybeCreateMilestoneNotification($event, $guestLabel);
}
private function maybeCreateMilestoneNotification(GuestPhotoUploaded $event, ?string $guestLabel): void
{
if (! $guestLabel) {
return;
}
$count = Photo::where('event_id', $event->event->id)
->where('guest_name', $event->guestIdentifier)
->count();
if (! in_array($count, $this->milestones, true)) {
return;
}
$title = sprintf('%s hat ein neues Achievement erreicht 🏅', $guestLabel);
$body = sprintf('%s hat jetzt %d Fotos beigetragen weiter so!', $guestLabel, $count);
$this->notifications->createNotification(
$event->event,
GuestNotificationType::ACHIEVEMENT_MAJOR,
$title,
$body,
[
'audience_scope' => GuestNotificationAudience::ALL,
'expires_at' => now()->addDay(),
]
);
}
private function resolveGuestLabel(?string $guestName, string $guestIdentifier): ?string
{
$name = $guestName && trim($guestName) !== '' ? trim($guestName) : null;
if ($name) {
return $name;
}
if ($guestIdentifier === 'anon') {
return null;
}
return $guestIdentifier;
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Providers;
use App\Events\GuestPhotoUploaded;
use App\Events\Packages\EventPackageGalleryExpired;
use App\Events\Packages\EventPackageGalleryExpiring;
use App\Events\Packages\EventPackageGuestLimitReached;
@@ -13,6 +14,7 @@ use App\Events\Packages\TenantPackageEventLimitReached;
use App\Events\Packages\TenantPackageEventThresholdReached;
use App\Events\Packages\TenantPackageExpired;
use App\Events\Packages\TenantPackageExpiring;
use App\Listeners\GuestNotifications\SendPhotoUploadedNotification;
use App\Listeners\Packages\QueueGalleryExpiredNotification;
use App\Listeners\Packages\QueueGalleryWarningNotification;
use App\Listeners\Packages\QueueGuestLimitNotification;
@@ -118,6 +120,11 @@ class AppServiceProvider extends ServiceProvider
[QueueTenantCreditsLowNotification::class, 'handle']
);
EventFacade::listen(
GuestPhotoUploaded::class,
[SendPhotoUploadedNotification::class, 'handle']
);
RateLimiter::for('tenant-api', function (Request $request) {
$tenantId = $request->attributes->get('tenant_id')
?? $request->user()?->tenant_id