diff --git a/app/Console/Commands/SendGuestFeedbackReminders.php b/app/Console/Commands/SendGuestFeedbackReminders.php new file mode 100644 index 0000000..00064bd --- /dev/null +++ b/app/Console/Commands/SendGuestFeedbackReminders.php @@ -0,0 +1,64 @@ +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; + } +} diff --git a/app/Events/GuestPhotoUploaded.php b/app/Events/GuestPhotoUploaded.php new file mode 100644 index 0000000..76822b8 --- /dev/null +++ b/app/Events/GuestPhotoUploaded.php @@ -0,0 +1,22 @@ +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; } diff --git a/app/Listeners/GuestNotifications/SendPhotoUploadedNotification.php b/app/Listeners/GuestNotifications/SendPhotoUploadedNotification.php new file mode 100644 index 0000000..38e1c1d --- /dev/null +++ b/app/Listeners/GuestNotifications/SendPhotoUploadedNotification.php @@ -0,0 +1,88 @@ +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; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 6602765..db3c1e8 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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 diff --git a/bootstrap/app.php b/bootstrap/app.php index d8e9bb3..315bf9a 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -29,6 +29,7 @@ return Application::configure(basePath: dirname(__DIR__)) \App\Console\Commands\CheckUploadQueuesCommand::class, \App\Console\Commands\PurgeExpiredDataExports::class, \App\Console\Commands\ProcessTenantRetention::class, + \App\Console\Commands\SendGuestFeedbackReminders::class, ]) ->withSchedule(function (\Illuminate\Console\Scheduling\Schedule $schedule) { $schedule->command('package:check-status')->dailyAt('06:00'); @@ -36,6 +37,7 @@ return Application::configure(basePath: dirname(__DIR__)) $schedule->command('photobooth:ingest')->everyFiveMinutes()->withoutOverlapping(); $schedule->command('exports:purge')->dailyAt('02:00'); $schedule->command('tenants:retention-scan')->dailyAt('03:00'); + $schedule->command('guest:feedback-reminders')->dailyAt('22:00'); }) ->withMiddleware(function (Middleware $middleware) { $middleware->alias([ diff --git a/tests/Feature/Api/EventGuestUploadLimitTest.php b/tests/Feature/Api/EventGuestUploadLimitTest.php index 64799de..d038fe0 100644 --- a/tests/Feature/Api/EventGuestUploadLimitTest.php +++ b/tests/Feature/Api/EventGuestUploadLimitTest.php @@ -9,8 +9,10 @@ use App\Models\Event; use App\Models\EventPackage; use App\Models\MediaStorageTarget; use App\Models\Package; +use App\Models\Photo; use App\Models\Tenant; use App\Services\EventJoinTokenService; +use App\Services\Packages\PackageLimitEvaluator; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Bus; @@ -82,6 +84,12 @@ class EventGuestUploadLimitTest extends TestCase $response->assertJsonPath('error.code', 'photo_limit_exceeded'); Bus::assertNothingDispatched(); + + $this->assertDatabaseHas('guest_notifications', [ + 'event_id' => $event->id, + 'type' => 'upload_alert', + 'audience_scope' => 'all', + ]); } public function test_guest_upload_increments_usage_and_succeeds(): void @@ -167,4 +175,81 @@ class EventGuestUploadLimitTest extends TestCase $response->assertJsonPath('limits.gallery.state', 'warning'); $response->assertJsonPath('limits.can_upload_photos', true); } + + public function test_device_limit_creates_targeted_notification(): void + { + $tenant = Tenant::factory()->create(); + $event = Event::factory()->for($tenant)->create([ + 'status' => 'published', + ]); + + $mock = \Mockery::mock(PackageLimitEvaluator::class); + $mock->shouldReceive('assessPhotoUpload')->andReturn(null); + $mock->shouldReceive('resolveEventPackageForPhotoUpload')->andReturn(null); + $this->instance(PackageLimitEvaluator::class, $mock); + + Photo::factory()->count(50)->for($event)->create([ + 'guest_name' => 'device-abc', + ]); + + $emotion = Emotion::factory()->create(); + $emotion->eventTypes()->attach($event->event_type_id); + + $token = app(EventJoinTokenService::class)->createToken($event)->plain_token; + + $response = $this->post("/api/v1/events/{$token}/upload", [ + 'photo' => UploadedFile::fake()->image('limit-device.jpg', 800, 600), + ], [ + 'X-Device-Id' => 'device-abc', + ]); + + $response->assertStatus(429); + + $this->assertDatabaseHas('guest_notifications', [ + 'event_id' => $event->id, + 'type' => 'upload_alert', + 'target_identifier' => 'device-abc', + 'audience_scope' => 'guest', + ]); + } + + public function test_photo_limit_violation_creates_notification(): void + { + $tenant = Tenant::factory()->create(); + $event = Event::factory()->for($tenant)->create([ + 'status' => 'published', + ]); + + $violation = [ + 'code' => 'photo_limit_exceeded', + 'title' => 'Photo upload limit reached', + 'message' => 'Limit reached', + 'status' => 402, + 'meta' => ['scope' => 'photos'], + ]; + + $mock = \Mockery::mock(PackageLimitEvaluator::class); + $mock->shouldReceive('assessPhotoUpload')->andReturn($violation); + $mock->shouldReceive('resolveEventPackageForPhotoUpload')->andReturn(null); + $this->instance(PackageLimitEvaluator::class, $mock); + + $emotion = Emotion::factory()->create(); + $emotion->eventTypes()->attach($event->event_type_id); + + $token = app(EventJoinTokenService::class)->createToken($event)->plain_token; + + $response = $this->post("/api/v1/events/{$token}/upload", [ + 'photo' => UploadedFile::fake()->image('limit.jpg', 800, 600), + ], [ + 'X-Device-Id' => 'device-xyz', + ]); + + $response->assertStatus(402); + + $this->assertDatabaseHas('guest_notifications', [ + 'event_id' => $event->id, + 'type' => 'upload_alert', + 'audience_scope' => 'all', + ]); + } } diff --git a/tests/Feature/Console/SendGuestFeedbackRemindersTest.php b/tests/Feature/Console/SendGuestFeedbackRemindersTest.php new file mode 100644 index 0000000..ffe86a8 --- /dev/null +++ b/tests/Feature/Console/SendGuestFeedbackRemindersTest.php @@ -0,0 +1,35 @@ +create([ + 'status' => 'published', + 'is_active' => true, + 'date' => now()->subDay(), + ]); + + $this->artisan('guest:feedback-reminders', ['--hours' => 0]) + ->assertExitCode(0); + + $this->assertDatabaseHas('guest_notifications', [ + 'event_id' => $event->id, + 'type' => 'feedback_request', + ]); + + // Running again should not duplicate notifications + $this->artisan('guest:feedback-reminders', ['--hours' => 0]) + ->assertExitCode(0); + + $this->assertDatabaseCount('guest_notifications', 1); + } +} diff --git a/tests/Feature/Listeners/SendPhotoUploadedNotificationTest.php b/tests/Feature/Listeners/SendPhotoUploadedNotificationTest.php new file mode 100644 index 0000000..f8b2ea9 --- /dev/null +++ b/tests/Feature/Listeners/SendPhotoUploadedNotificationTest.php @@ -0,0 +1,42 @@ +create(['status' => 'published']); + $photo = Photo::factory()->for($event)->create(['guest_name' => 'Anne']); + + event(new GuestPhotoUploaded($event->refresh(), $photo->id, 'Anne', 'Anne')); + + $this->assertDatabaseHas('guest_notifications', [ + 'event_id' => $event->id, + 'type' => GuestNotificationType::PHOTO_ACTIVITY->value, + ]); + } + + public function test_milestone_creates_achievement_notification(): void + { + $event = Event::factory()->create(['status' => 'published']); + Photo::factory()->count(4)->for($event)->create(['guest_name' => 'Mia']); + $photo = Photo::factory()->for($event)->create(['guest_name' => 'Mia']); + + event(new GuestPhotoUploaded($event->refresh(), $photo->id, 'Mia', 'Mia')); + + $this->assertDatabaseHas('guest_notifications', [ + 'event_id' => $event->id, + 'type' => GuestNotificationType::ACHIEVEMENT_MAJOR->value, + ]); + } +}