- {(
- [
- { key: 'all', label: t('header.notifications.scope.all', 'Alle') },
- { key: 'tips', label: t('header.notifications.scope.tips', 'Tipps & Achievements') },
- { key: 'general', label: t('header.notifications.scope.general', 'Allgemein') },
- ] as const
- ).map((option) => (
-
- ))}
+ {activeTab !== 'uploads' && (
+
+
+ {(
+ [
+ { key: 'all', label: t('header.notifications.scope.all', 'Alle') },
+ { key: 'tips', label: t('header.notifications.scope.tips', 'Tipps & Achievements') },
+ { key: 'general', label: t('header.notifications.scope.general', 'Allgemein') },
+ ] as const
+ ).map((option) => (
+
+ ))}
+
-
- {activeTab === 'status' && (
+ )}
+ {activeTab === 'uploads' && (center.pendingCount > 0 || center.queueCount > 0) && (
{center.pendingCount > 0 && (
@@ -478,30 +447,32 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
)}
)}
- {taskProgress && (
-
-
-
-
{t('header.notifications.badgeLabel', 'Badge-Fortschritt')}
-
- {taskProgress.completedCount}/{TASK_BADGE_TARGET}
-
-
-
- {t('header.notifications.tasksCta', 'Weiter')}
-
-
-
-
+ {center.loading ? (
+
+ ) : scopedNotifications.length === 0 ? (
+
+ ) : (
+ scopedNotifications.map((item) => (
+ center.markAsRead(item.id)}
+ onDismiss={() => center.dismiss(item.id)}
+ t={t}
/>
-
-
- )}
+ ))
+ )}
+
({
queueItems: [],
queueCount: 0,
pendingCount: 0,
- totalCount: 0,
loading: false,
pendingLoading: false,
refresh: vi.fn(),
@@ -97,10 +96,10 @@ describe('Header notifications toggle', () => {
const bellButton = screen.getByLabelText('Benachrichtigungen anzeigen');
fireEvent.click(bellButton);
- expect(screen.getByText('Benachrichtigungen')).toBeInTheDocument();
+ expect(screen.getByText('Updates')).toBeInTheDocument();
fireEvent.click(bellButton);
- expect(screen.queryByText('Benachrichtigungen')).not.toBeInTheDocument();
+ expect(screen.queryByText('Updates')).not.toBeInTheDocument();
});
});
diff --git a/resources/js/guest/context/NotificationCenterContext.tsx b/resources/js/guest/context/NotificationCenterContext.tsx
index 817b8f0..ef023c0 100644
--- a/resources/js/guest/context/NotificationCenterContext.tsx
+++ b/resources/js/guest/context/NotificationCenterContext.tsx
@@ -16,7 +16,6 @@ export type NotificationCenterValue = {
queueItems: QueueItem[];
queueCount: number;
pendingCount: number;
- totalCount: number;
loading: boolean;
pendingLoading: boolean;
refresh: () => Promise;
@@ -264,11 +263,9 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
}, [loadNotifications, refreshQueue, loadPendingUploads]);
const loading = loadingNotifications || queueLoading || pendingLoading;
- const totalCount = unreadCount + queueCount + pendingCount;
-
React.useEffect(() => {
- void updateAppBadge(totalCount);
- }, [totalCount]);
+ void updateAppBadge(unreadCount);
+ }, [unreadCount]);
const value: NotificationCenterValue = {
notifications,
@@ -276,7 +273,6 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
queueItems: items,
queueCount,
pendingCount,
- totalCount,
loading,
pendingLoading,
refresh,
diff --git a/resources/js/guest/i18n/messages.ts b/resources/js/guest/i18n/messages.ts
index 6a8291f..83b15ec 100644
--- a/resources/js/guest/i18n/messages.ts
+++ b/resources/js/guest/i18n/messages.ts
@@ -42,7 +42,13 @@ export const messages: Record = {
},
helpGallery: 'Hilfe zu Galerie & Teilen',
notifications: {
- tabStatus: 'Upload-Status',
+ title: 'Updates',
+ unread: '{count} neu',
+ allRead: 'Alles gelesen',
+ tabUnread: 'Nachrichten',
+ tabUploads: 'Uploads',
+ tabAll: 'Alle Updates',
+ emptyStatus: 'Keine Upload-Hinweise oder Wartungen aktiv.',
},
},
liveShowPlayer: {
@@ -774,7 +780,13 @@ export const messages: Record = {
},
helpGallery: 'Help: Gallery & sharing',
notifications: {
- tabStatus: 'Upload status',
+ title: 'Updates',
+ unread: '{count} new',
+ allRead: 'All read',
+ tabUnread: 'Messages',
+ tabUploads: 'Uploads',
+ tabAll: 'All updates',
+ emptyStatus: 'No upload status or maintenance active.',
},
},
liveShowPlayer: {
diff --git a/routes/api.php b/routes/api.php
index e766285..ffa82fc 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -6,6 +6,7 @@ use App\Http\Controllers\Api\LegalController;
use App\Http\Controllers\Api\LiveShowController;
use App\Http\Controllers\Api\Marketing\CouponPreviewController;
use App\Http\Controllers\Api\PackageController;
+use App\Http\Controllers\Api\PhotoboothConnectController;
use App\Http\Controllers\Api\SparkboothUploadController;
use App\Http\Controllers\Api\Tenant\AdminPushSubscriptionController;
use App\Http\Controllers\Api\Tenant\DashboardController;
@@ -24,6 +25,7 @@ use App\Http\Controllers\Api\Tenant\LiveShowLinkController;
use App\Http\Controllers\Api\Tenant\LiveShowPhotoController;
use App\Http\Controllers\Api\Tenant\NotificationLogController;
use App\Http\Controllers\Api\Tenant\OnboardingController;
+use App\Http\Controllers\Api\Tenant\PhotoboothConnectCodeController;
use App\Http\Controllers\Api\Tenant\PhotoboothController;
use App\Http\Controllers\Api\Tenant\PhotoController;
use App\Http\Controllers\Api\Tenant\ProfileController;
@@ -153,6 +155,9 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::post('/photobooth/sparkbooth/upload', [SparkboothUploadController::class, 'store'])
->name('photobooth.sparkbooth.upload');
+ Route::post('/photobooth/connect', [PhotoboothConnectController::class, 'store'])
+ ->middleware('throttle:photobooth-connect')
+ ->name('photobooth.connect');
Route::get('/tenant/events/{event:slug}/photos/{photo}/{variant}/asset', [PhotoController::class, 'asset'])
->whereNumber('photo')
@@ -263,6 +268,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::post('/enable', [PhotoboothController::class, 'enable'])->name('tenant.events.photobooth.enable');
Route::post('/rotate', [PhotoboothController::class, 'rotate'])->name('tenant.events.photobooth.rotate');
Route::post('/disable', [PhotoboothController::class, 'disable'])->name('tenant.events.photobooth.disable');
+ Route::post('/connect-codes', [PhotoboothConnectCodeController::class, 'store'])
+ ->name('tenant.events.photobooth.connect-codes.store');
});
Route::get('members', [EventMemberController::class, 'index'])
@@ -353,6 +360,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::post('/complete', [PackageController::class, 'completePurchase'])->name('packages.complete');
Route::post('/free', [PackageController::class, 'assignFree'])->name('packages.free');
Route::post('/paddle-checkout', [PackageController::class, 'createPaddleCheckout'])->name('packages.paddle-checkout');
+ Route::get('/checkout-session/{session}/status', [PackageController::class, 'checkoutSessionStatus'])
+ ->name('packages.checkout-session.status');
});
Route::get('addons/catalog', [EventAddonCatalogController::class, 'index'])
diff --git a/tests/Feature/Photobooth/PhotoboothConnectCodeTest.php b/tests/Feature/Photobooth/PhotoboothConnectCodeTest.php
new file mode 100644
index 0000000..68bdb6b
--- /dev/null
+++ b/tests/Feature/Photobooth/PhotoboothConnectCodeTest.php
@@ -0,0 +1,100 @@
+for($this->tenant)->create([
+ 'slug' => 'connect-code-event',
+ ]);
+
+ EventPhotoboothSetting::factory()
+ ->for($event)
+ ->activeSparkbooth()
+ ->create([
+ 'username' => 'pbconnect',
+ 'password' => 'SECRET12',
+ ]);
+
+ $response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/photobooth/connect-codes");
+
+ $response->assertOk()
+ ->assertJsonPath('data.code', fn ($value) => is_string($value) && strlen($value) === 6)
+ ->assertJsonPath('data.expires_at', fn ($value) => is_string($value) && $value !== '');
+
+ $this->assertDatabaseCount('photobooth_connect_codes', 1);
+ }
+
+ #[Test]
+ public function it_redeems_a_connect_code_and_returns_upload_credentials(): void
+ {
+ $event = Event::factory()->for($this->tenant)->create([
+ 'slug' => 'connect-code-redeem',
+ ]);
+
+ EventPhotoboothSetting::factory()
+ ->for($event)
+ ->activeSparkbooth()
+ ->create([
+ 'username' => 'pbconnect',
+ 'password' => 'SECRET12',
+ ]);
+
+ $codeResponse = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/photobooth/connect-codes");
+ $codeResponse->assertOk();
+
+ $code = (string) $codeResponse->json('data.code');
+
+ $redeem = $this->postJson('/api/v1/photobooth/connect', [
+ 'code' => $code,
+ ]);
+
+ $redeem->assertOk()
+ ->assertJsonPath('data.upload_url', fn ($value) => is_string($value) && $value !== '')
+ ->assertJsonPath('data.username', 'pbconnect')
+ ->assertJsonPath('data.password', 'SECRET12');
+
+ $this->assertDatabaseHas('photobooth_connect_codes', [
+ 'event_id' => $event->id,
+ ]);
+ }
+
+ #[Test]
+ public function it_rejects_expired_connect_codes(): void
+ {
+ $event = Event::factory()->for($this->tenant)->create([
+ 'slug' => 'connect-code-expired',
+ ]);
+
+ EventPhotoboothSetting::factory()
+ ->for($event)
+ ->activeSparkbooth()
+ ->create([
+ 'username' => 'pbconnect',
+ 'password' => 'SECRET12',
+ ]);
+
+ $code = '123456';
+
+ PhotoboothConnectCode::query()->create([
+ 'event_id' => $event->id,
+ 'code_hash' => hash('sha256', $code),
+ 'expires_at' => now()->subMinute(),
+ ]);
+
+ $response = $this->postJson('/api/v1/photobooth/connect', [
+ 'code' => $code,
+ ]);
+
+ $response->assertStatus(422);
+ }
+}
diff --git a/tests/Feature/Tenant/PhotoModerationControllerTest.php b/tests/Feature/Tenant/PhotoModerationControllerTest.php
new file mode 100644
index 0000000..567125e
--- /dev/null
+++ b/tests/Feature/Tenant/PhotoModerationControllerTest.php
@@ -0,0 +1,26 @@
+for($this->tenant)->create([
+ 'slug' => 'moderation-event',
+ ]);
+ $photo = Photo::factory()->for($event)->create([
+ 'status' => 'pending',
+ ]);
+
+ $response = $this->authenticatedRequest('PATCH', "/api/v1/tenant/events/{$event->slug}/photos/{$photo->id}", [
+ 'status' => 'approved',
+ ]);
+
+ $response->assertOk();
+ $this->assertSame('approved', $photo->refresh()->status);
+ }
+}
diff --git a/tests/Feature/Tenant/TenantCheckoutSessionStatusTest.php b/tests/Feature/Tenant/TenantCheckoutSessionStatusTest.php
new file mode 100644
index 0000000..77e38de
--- /dev/null
+++ b/tests/Feature/Tenant/TenantCheckoutSessionStatusTest.php
@@ -0,0 +1,46 @@
+create([
+ 'price' => 129,
+ ]);
+
+ $session = CheckoutSession::create([
+ 'id' => (string) Str::uuid(),
+ 'user_id' => $this->tenantUser->id,
+ 'tenant_id' => $this->tenant->id,
+ 'package_id' => $package->id,
+ 'status' => CheckoutSession::STATUS_FAILED,
+ 'provider' => CheckoutSession::PROVIDER_PADDLE,
+ 'provider_metadata' => [
+ 'paddle_checkout_url' => 'https://checkout.paddle.test/checkout/123',
+ ],
+ 'status_history' => [
+ [
+ 'status' => CheckoutSession::STATUS_FAILED,
+ 'reason' => 'paddle_failed',
+ 'at' => now()->toIso8601String(),
+ ],
+ ],
+ ]);
+
+ $response = $this->authenticatedRequest(
+ 'GET',
+ "/api/v1/tenant/packages/checkout-session/{$session->id}/status"
+ );
+
+ $response->assertOk()
+ ->assertJsonPath('status', CheckoutSession::STATUS_FAILED)
+ ->assertJsonPath('reason', 'paddle_failed')
+ ->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123');
+ }
+}
diff --git a/tests/Feature/Tenant/TenantPaddleCheckoutTest.php b/tests/Feature/Tenant/TenantPaddleCheckoutTest.php
index 4156b20..76fc651 100644
--- a/tests/Feature/Tenant/TenantPaddleCheckoutTest.php
+++ b/tests/Feature/Tenant/TenantPaddleCheckoutTest.php
@@ -29,7 +29,10 @@ class TenantPaddleCheckoutTest extends TenantTestCase
return $tenant->is($this->tenant)
&& $payloadPackage->is($package)
&& array_key_exists('success_url', $payload)
- && array_key_exists('return_url', $payload);
+ && array_key_exists('return_url', $payload)
+ && array_key_exists('metadata', $payload)
+ && is_array($payload['metadata'])
+ && ! empty($payload['metadata']['checkout_session_id']);
})
->andReturn([
'checkout_url' => 'https://checkout.paddle.test/checkout/123',
@@ -42,7 +45,8 @@ class TenantPaddleCheckoutTest extends TenantTestCase
]);
$response->assertOk()
- ->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123');
+ ->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123')
+ ->assertJsonStructure(['checkout_session_id']);
}
public function test_paddle_checkout_requires_paddle_price_id(): void
diff --git a/tests/Unit/RateLimitConfigTest.php b/tests/Unit/RateLimitConfigTest.php
new file mode 100644
index 0000000..5f4a14d
--- /dev/null
+++ b/tests/Unit/RateLimitConfigTest.php
@@ -0,0 +1,67 @@
+ '10.0.0.1',
+ ]);
+ $request->attributes->set('tenant_id', 42);
+
+ $limiter = RateLimiter::limiter('tenant-api');
+
+ $this->assertNotNull($limiter);
+
+ $limit = $limiter($request);
+
+ $this->assertInstanceOf(Limit::class, $limit);
+ $this->assertSame(600, $limit->maxAttempts);
+ }
+
+ public function test_guest_api_rate_limiter_allows_higher_throughput(): void
+ {
+ $request = Request::create('/api/v1/events/sample', 'GET', [], [], [], [
+ 'REMOTE_ADDR' => '10.0.0.2',
+ ]);
+
+ $limiter = RateLimiter::limiter('guest-api');
+
+ $this->assertNotNull($limiter);
+
+ $limit = $limiter($request);
+
+ $this->assertInstanceOf(Limit::class, $limit);
+ $this->assertSame(300, $limit->maxAttempts);
+ }
+
+ public function test_guest_policy_defaults_follow_join_token_limits(): void
+ {
+ $accessLimit = 300;
+ $downloadLimit = 120;
+
+ config([
+ 'join_tokens.access_limit' => $accessLimit,
+ 'join_tokens.download_limit' => $downloadLimit,
+ ]);
+
+ GuestPolicySetting::query()->delete();
+ GuestPolicySetting::flushCache();
+
+ $settings = GuestPolicySetting::current();
+
+ $this->assertSame($accessLimit, $settings->join_token_access_limit);
+ $this->assertSame($downloadLimit, $settings->join_token_download_limit);
+ }
+}
diff --git a/tests/Unit/SendPhotoUploadedNotificationTest.php b/tests/Unit/SendPhotoUploadedNotificationTest.php
new file mode 100644
index 0000000..2fc38bf
--- /dev/null
+++ b/tests/Unit/SendPhotoUploadedNotificationTest.php
@@ -0,0 +1,144 @@
+create();
+ $listener = $this->app->make(SendPhotoUploadedNotification::class);
+
+ GuestNotification::factory()->create([
+ 'tenant_id' => $event->tenant_id,
+ 'event_id' => $event->id,
+ 'type' => GuestNotificationType::PHOTO_ACTIVITY,
+ 'title' => 'Fotospiel-Test hat gerade ein Foto gemacht 🎉',
+ 'payload' => [
+ 'photo_id' => 123,
+ 'photo_ids' => [123],
+ 'count' => 1,
+ ],
+ 'created_at' => now()->subSeconds(5),
+ 'updated_at' => now()->subSeconds(5),
+ ]);
+
+ $listener->handle(new GuestPhotoUploaded(
+ $event,
+ 123,
+ 'device-123',
+ 'Fotospiel-Test'
+ ));
+
+ $notification = GuestNotification::query()
+ ->where('event_id', $event->id)
+ ->where('type', GuestNotificationType::PHOTO_ACTIVITY)
+ ->first();
+
+ $this->assertSame(1, GuestNotification::query()
+ ->where('event_id', $event->id)
+ ->where('type', GuestNotificationType::PHOTO_ACTIVITY)
+ ->count());
+ $this->assertSame(1, (int) ($notification?->payload['count'] ?? 0));
+ }
+
+ public function test_it_groups_recent_photo_activity_notifications(): void
+ {
+ Carbon::setTestNow('2026-01-12 13:48:01');
+
+ $event = Event::factory()->create();
+ $listener = $this->app->make(SendPhotoUploadedNotification::class);
+
+ GuestNotification::factory()->create([
+ 'tenant_id' => $event->tenant_id,
+ 'event_id' => $event->id,
+ 'type' => GuestNotificationType::PHOTO_ACTIVITY,
+ 'title' => 'Fotospiel-Test hat gerade ein Foto gemacht 🎉',
+ 'payload' => [
+ 'photo_id' => 122,
+ 'photo_ids' => [122],
+ 'count' => 1,
+ ],
+ 'created_at' => now()->subMinutes(5),
+ 'updated_at' => now()->subMinutes(5),
+ ]);
+
+ $listener->handle(new GuestPhotoUploaded(
+ $event,
+ 123,
+ 'device-123',
+ 'Fotospiel-Test'
+ ));
+
+ $this->assertSame(1, GuestNotification::query()
+ ->where('event_id', $event->id)
+ ->where('type', GuestNotificationType::PHOTO_ACTIVITY)
+ ->count());
+
+ $notification = GuestNotification::query()
+ ->where('event_id', $event->id)
+ ->where('type', GuestNotificationType::PHOTO_ACTIVITY)
+ ->first();
+
+ $this->assertSame('Es gibt 2 neue Fotos!', $notification?->title);
+ $this->assertSame(2, (int) ($notification?->payload['count'] ?? 0));
+
+ $this->assertSame(1, GuestNotificationReceipt::query()
+ ->where('guest_identifier', 'device-123')
+ ->where('status', 'read')
+ ->count());
+ }
+
+ public function test_it_creates_notification_outside_group_window(): void
+ {
+ Carbon::setTestNow('2026-01-12 13:48:01');
+
+ $event = Event::factory()->create();
+ $listener = $this->app->make(SendPhotoUploadedNotification::class);
+
+ GuestNotification::factory()->create([
+ 'tenant_id' => $event->tenant_id,
+ 'event_id' => $event->id,
+ 'type' => GuestNotificationType::PHOTO_ACTIVITY,
+ 'title' => 'Fotospiel-Test hat gerade ein Foto gemacht 🎉',
+ 'payload' => [
+ 'photo_id' => 122,
+ 'photo_ids' => [122],
+ 'count' => 1,
+ ],
+ 'created_at' => now()->subMinutes(20),
+ 'updated_at' => now()->subMinutes(20),
+ ]);
+
+ $listener->handle(new GuestPhotoUploaded(
+ $event,
+ 123,
+ 'device-123',
+ 'Fotospiel-Test'
+ ));
+
+ $this->assertSame(2, GuestNotification::query()
+ ->where('event_id', $event->id)
+ ->where('type', GuestNotificationType::PHOTO_ACTIVITY)
+ ->count());
+
+ $this->assertSame(1, GuestNotificationReceipt::query()
+ ->where('guest_identifier', 'device-123')
+ ->where('status', 'read')
+ ->count());
+ }
+}