diff --git a/.beads/last-touched b/.beads/last-touched index 7c0dc08..4d1f588 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -fotospiel-app-9em +fotospiel-app-29r diff --git a/.gitignore b/.gitignore index 0349bda..8903637 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ yarn-error.log /.vscode test-results GEMINI.md +.beads/.sync.lock +.beads/daemon-error +.beads/sync_base.jsonl diff --git a/app/Http/Controllers/Api/PackageController.php b/app/Http/Controllers/Api/PackageController.php index bf803ec..ecbcfb2 100644 --- a/app/Http/Controllers/Api/PackageController.php +++ b/app/Http/Controllers/Api/PackageController.php @@ -3,9 +3,12 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Http\Requests\Checkout\CheckoutSessionStatusRequest; +use App\Models\CheckoutSession; use App\Models\Package; use App\Models\PackagePurchase; use App\Models\TenantPackage; +use App\Services\Checkout\CheckoutSessionService; use App\Services\Paddle\PaddleCheckoutService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -14,7 +17,10 @@ use Illuminate\Validation\ValidationException; class PackageController extends Controller { - public function __construct(private readonly PaddleCheckoutService $paddleCheckout) {} + public function __construct( + private readonly PaddleCheckoutService $paddleCheckout, + private readonly CheckoutSessionService $sessions, + ) {} public function index(Request $request): JsonResponse { @@ -165,23 +171,82 @@ class PackageController extends Controller $package = Package::findOrFail($request->integer('package_id')); $tenant = $request->attributes->get('tenant'); + $user = $request->user(); if (! $tenant) { throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']); } + if (! $user) { + throw ValidationException::withMessages(['user' => 'User context missing.']); + } + if (! $package->paddle_price_id) { throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']); } + $session = $this->sessions->createOrResume($user, $package, [ + 'tenant' => $tenant, + ]); + + $this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE); + + $now = now(); + + $session->forceFill([ + 'accepted_terms_at' => $now, + 'accepted_privacy_at' => $now, + 'accepted_withdrawal_notice_at' => $now, + 'digital_content_waiver_at' => null, + 'legal_version' => config('app.legal_version', $now->toDateString()), + ])->save(); + $payload = [ 'success_url' => $request->input('success_url'), 'return_url' => $request->input('return_url'), + 'metadata' => [ + 'checkout_session_id' => $session->id, + 'legal_version' => $session->legal_version, + 'accepted_terms' => true, + ], ]; $checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload); - return response()->json($checkout); + $session->forceFill([ + 'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id, + 'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([ + 'paddle_checkout_id' => $checkout['id'] ?? null, + 'paddle_checkout_url' => $checkout['checkout_url'] ?? null, + 'paddle_expires_at' => $checkout['expires_at'] ?? null, + ])), + ])->save(); + + return response()->json(array_merge($checkout, [ + 'checkout_session_id' => $session->id, + ])); + } + + public function checkoutSessionStatus(CheckoutSessionStatusRequest $request, CheckoutSession $session): JsonResponse + { + $history = $session->status_history ?? []; + $reason = null; + + foreach (array_reverse($history) as $entry) { + if (($entry['status'] ?? null) === $session->status) { + $reason = $entry['reason'] ?? null; + break; + } + } + + $checkoutUrl = data_get($session->provider_metadata ?? [], 'paddle_checkout_url'); + + return response()->json([ + 'status' => $session->status, + 'completed_at' => optional($session->completed_at)->toIso8601String(), + 'reason' => $reason, + 'checkout_url' => is_string($checkoutUrl) ? $checkoutUrl : null, + ]); } private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse diff --git a/app/Http/Controllers/Api/PhotoboothConnectController.php b/app/Http/Controllers/Api/PhotoboothConnectController.php new file mode 100644 index 0000000..aef8377 --- /dev/null +++ b/app/Http/Controllers/Api/PhotoboothConnectController.php @@ -0,0 +1,45 @@ +service->redeem($request->input('code')); + + if (! $record) { + return response()->json([ + 'message' => __('Ungültiger oder abgelaufener Verbindungscode.'), + ], 422); + } + + $record->loadMissing('event.photoboothSetting'); + $event = $record->event; + $setting = $event?->photoboothSetting; + + if (! $event || ! $setting || ! $setting->enabled || $setting->mode !== 'sparkbooth') { + return response()->json([ + 'message' => __('Photobooth ist nicht im Sparkbooth-Modus aktiv.'), + ], 409); + } + + return response()->json([ + 'data' => [ + 'upload_url' => route('api.v1.photobooth.sparkbooth.upload'), + 'username' => $setting->username, + 'password' => $setting->password, + 'expires_at' => optional($setting->expires_at)->toIso8601String(), + 'response_format' => ($setting->metadata ?? [])['sparkbooth_response_format'] + ?? config('photobooth.sparkbooth.response_format', 'json'), + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/Tenant/PhotoController.php b/app/Http/Controllers/Api/Tenant/PhotoController.php index 25c8ebf..5429342 100644 --- a/app/Http/Controllers/Api/Tenant/PhotoController.php +++ b/app/Http/Controllers/Api/Tenant/PhotoController.php @@ -525,13 +525,13 @@ class PhotoController extends Controller ]); // Only tenant admins can moderate - if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant:write')) { + if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant-admin')) { return ApiError::response( 'insufficient_scope', 'Insufficient Scopes', 'You are not allowed to moderate photos for this event.', Response::HTTP_FORBIDDEN, - ['required_scope' => 'tenant:write'] + ['required_scope' => 'tenant-admin'] ); } @@ -823,6 +823,11 @@ class PhotoController extends Controller private function tokenHasScope(Request $request, string $scope): bool { + $accessToken = $request->user()?->currentAccessToken(); + if ($accessToken && $accessToken->can($scope)) { + return true; + } + $scopes = $request->user()->scopes ?? ($request->attributes->get('decoded_token')['scopes'] ?? []); if (! is_array($scopes)) { diff --git a/app/Http/Controllers/Api/Tenant/PhotoboothConnectCodeController.php b/app/Http/Controllers/Api/Tenant/PhotoboothConnectCodeController.php new file mode 100644 index 0000000..f473b21 --- /dev/null +++ b/app/Http/Controllers/Api/Tenant/PhotoboothConnectCodeController.php @@ -0,0 +1,47 @@ +assertEventBelongsToTenant($request, $event); + + $event->loadMissing('photoboothSetting'); + $setting = $event->photoboothSetting; + + if (! $setting || ! $setting->enabled || $setting->mode !== 'sparkbooth') { + return response()->json([ + 'message' => __('Photobooth muss im Sparkbooth-Modus aktiviert sein.'), + ], 409); + } + + $expiresInMinutes = $request->input('expires_in_minutes'); + $result = $this->service->create($event, $expiresInMinutes ? (int) $expiresInMinutes : null); + + return response()->json([ + 'data' => [ + 'code' => $result['code'], + 'expires_at' => $result['expires_at']->toIso8601String(), + ], + ]); + } + + protected function assertEventBelongsToTenant(PhotoboothConnectCodeStoreRequest $request, Event $event): void + { + $tenantId = (int) $request->attributes->get('tenant_id'); + + if ($tenantId !== (int) $event->tenant_id) { + abort(403, 'Event gehört nicht zu diesem Tenant.'); + } + } +} diff --git a/app/Http/Requests/Photobooth/PhotoboothConnectRedeemRequest.php b/app/Http/Requests/Photobooth/PhotoboothConnectRedeemRequest.php new file mode 100644 index 0000000..d99296d --- /dev/null +++ b/app/Http/Requests/Photobooth/PhotoboothConnectRedeemRequest.php @@ -0,0 +1,37 @@ +|string> + */ + public function rules(): array + { + return [ + 'code' => ['required', 'string', 'size:6', 'regex:/^\d{6}$/'], + ]; + } + + protected function prepareForValidation(): void + { + $code = preg_replace('/\D+/', '', (string) $this->input('code')); + + $this->merge([ + 'code' => $code, + ]); + } +} diff --git a/app/Http/Requests/Tenant/PhotoboothConnectCodeStoreRequest.php b/app/Http/Requests/Tenant/PhotoboothConnectCodeStoreRequest.php new file mode 100644 index 0000000..6651e4c --- /dev/null +++ b/app/Http/Requests/Tenant/PhotoboothConnectCodeStoreRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'expires_in_minutes' => ['nullable', 'integer', 'min:1', 'max:120'], + ]; + } +} diff --git a/app/Listeners/GuestNotifications/SendPhotoUploadedNotification.php b/app/Listeners/GuestNotifications/SendPhotoUploadedNotification.php index d5cb599..1ba077b 100644 --- a/app/Listeners/GuestNotifications/SendPhotoUploadedNotification.php +++ b/app/Listeners/GuestNotifications/SendPhotoUploadedNotification.php @@ -5,11 +5,19 @@ namespace App\Listeners\GuestNotifications; use App\Enums\GuestNotificationAudience; use App\Enums\GuestNotificationType; use App\Events\GuestPhotoUploaded; +use App\Models\GuestNotification; use App\Models\Photo; use App\Services\GuestNotificationService; +use Illuminate\Support\Carbon; class SendPhotoUploadedNotification { + private const DEDUPE_WINDOW_SECONDS = 30; + + private const GROUP_WINDOW_MINUTES = 10; + + private const MAX_GROUP_PHOTOS = 6; + /** * @param int[] $milestones */ @@ -25,7 +33,20 @@ class SendPhotoUploadedNotification ? sprintf('%s hat gerade ein Foto gemacht 🎉', $guestLabel) : 'Es gibt neue Fotos!'; - $this->notifications->createNotification( + $recent = $this->findRecentPhotoNotification($event->event->id); + if ($recent) { + if ($this->shouldSkipDuplicate($recent, $event->photoId, $title)) { + return; + } + + $notification = $this->updateGroupedNotification($recent, $event->photoId); + $this->markUploaderRead($notification, $event->guestIdentifier); + $this->maybeCreateMilestoneNotification($event, $guestLabel); + + return; + } + + $notification = $this->notifications->createNotification( $event->event, GuestNotificationType::PHOTO_ACTIVITY, $title, @@ -34,11 +55,15 @@ class SendPhotoUploadedNotification 'audience_scope' => GuestNotificationAudience::ALL, 'payload' => [ 'photo_id' => $event->photoId, + 'photo_ids' => [$event->photoId], + 'count' => 1, ], 'expires_at' => now()->addHours(3), ] ); + $this->markUploaderRead($notification, $event->guestIdentifier); + $this->maybeCreateMilestoneNotification($event, $guestLabel); } @@ -87,4 +112,94 @@ class SendPhotoUploadedNotification return $guestIdentifier; } + + private function findRecentPhotoNotification(int $eventId): ?GuestNotification + { + $cutoff = Carbon::now()->subMinutes(self::GROUP_WINDOW_MINUTES); + + return GuestNotification::query() + ->where('event_id', $eventId) + ->where('type', GuestNotificationType::PHOTO_ACTIVITY) + ->active() + ->notExpired() + ->where('created_at', '>=', $cutoff) + ->orderByDesc('id') + ->first(); + } + + private function shouldSkipDuplicate(GuestNotification $notification, int $photoId, string $title): bool + { + $payload = $notification->payload; + if (is_array($payload)) { + $payloadIds = array_filter( + array_map( + fn ($value) => is_numeric($value) ? (int) $value : null, + (array) ($payload['photo_ids'] ?? []) + ), + fn ($value) => $value !== null && $value > 0 + ); + if (in_array($photoId, $payloadIds, true)) { + return true; + } + if (is_numeric($payload['photo_id'] ?? null) && (int) $payload['photo_id'] === $photoId) { + return true; + } + } + + $cutoff = Carbon::now()->subSeconds(self::DEDUPE_WINDOW_SECONDS); + if ($notification->created_at instanceof Carbon && $notification->created_at->greaterThanOrEqualTo($cutoff)) { + return $notification->title === $title; + } + + return false; + } + + private function updateGroupedNotification(GuestNotification $notification, int $photoId): GuestNotification + { + $payload = is_array($notification->payload) ? $notification->payload : []; + $photoIds = array_filter( + array_map( + fn ($value) => is_numeric($value) ? (int) $value : null, + (array) ($payload['photo_ids'] ?? []) + ), + fn ($value) => $value !== null && $value > 0 + ); + $photoIds[] = $photoId; + $photoIds = array_values(array_unique($photoIds)); + $photoIds = array_slice($photoIds, 0, self::MAX_GROUP_PHOTOS); + + $existingCount = is_numeric($payload['count'] ?? null) + ? max(1, (int) $payload['count']) + : max(1, count($photoIds) - 1); + $newCount = $existingCount + 1; + + $notification->forceFill([ + 'title' => $this->buildGroupedTitle($newCount), + 'payload' => [ + 'count' => $newCount, + 'photo_ids' => $photoIds, + ], + ])->save(); + + return $notification; + } + + private function buildGroupedTitle(int $count): string + { + if ($count <= 1) { + return 'Es gibt neue Fotos!'; + } + + return sprintf('Es gibt %d neue Fotos!', $count); + } + + private function markUploaderRead(GuestNotification $notification, string $guestIdentifier): void + { + $guestIdentifier = trim($guestIdentifier); + if ($guestIdentifier === '' || $guestIdentifier === 'anonymous') { + return; + } + + $this->notifications->markAsRead($notification, $guestIdentifier); + } } diff --git a/app/Models/PhotoboothConnectCode.php b/app/Models/PhotoboothConnectCode.php new file mode 100644 index 0000000..b6ef33c --- /dev/null +++ b/app/Models/PhotoboothConnectCode.php @@ -0,0 +1,25 @@ + */ + use HasFactory; + + protected $guarded = []; + + protected $casts = [ + 'expires_at' => 'datetime', + 'redeemed_at' => 'datetime', + ]; + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 64ede0a..a235fea 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -162,6 +162,10 @@ class AppServiceProvider extends ServiceProvider return Limit::perMinute(300)->by('guest-api:'.($request->ip() ?? 'unknown')); }); + RateLimiter::for('photobooth-connect', function (Request $request) { + return Limit::perMinute(30)->by('photobooth-connect:'.($request->ip() ?? 'unknown')); + }); + RateLimiter::for('tenant-auth', function (Request $request) { return Limit::perMinute(20)->by('tenant-auth:'.($request->ip() ?? 'unknown')); }); diff --git a/app/Services/GuestNotificationService.php b/app/Services/GuestNotificationService.php index ed27b9b..8bbd94b 100644 --- a/app/Services/GuestNotificationService.php +++ b/app/Services/GuestNotificationService.php @@ -126,6 +126,36 @@ class GuestNotificationService return null; } + $photoId = Arr::get($payload, 'photo_id'); + if (is_numeric($photoId)) { + $photoId = max(1, (int) $photoId); + } else { + $photoId = null; + } + + $photoIds = Arr::get($payload, 'photo_ids'); + if (is_array($photoIds)) { + $photoIds = array_values(array_unique(array_filter(array_map(function ($value) { + if (! is_numeric($value)) { + return null; + } + + $int = (int) $value; + + return $int > 0 ? $int : null; + }, $photoIds)))); + $photoIds = array_slice($photoIds, 0, 10); + } else { + $photoIds = []; + } + + $count = Arr::get($payload, 'count'); + if (is_numeric($count)) { + $count = max(1, min(9999, (int) $count)); + } else { + $count = null; + } + $cta = Arr::get($payload, 'cta'); if (is_array($cta)) { $cta = [ @@ -142,6 +172,9 @@ class GuestNotificationService $clean = array_filter([ 'cta' => $cta, + 'photo_id' => $photoId, + 'photo_ids' => $photoIds, + 'count' => $count, ]); return $clean === [] ? null : $clean; diff --git a/app/Services/Photobooth/PhotoboothConnectCodeService.php b/app/Services/Photobooth/PhotoboothConnectCodeService.php new file mode 100644 index 0000000..55ad823 --- /dev/null +++ b/app/Services/Photobooth/PhotoboothConnectCodeService.php @@ -0,0 +1,80 @@ +where('code_hash', $candidateHash) + ->whereNull('redeemed_at') + ->where('expires_at', '>=', now()) + ->exists(); + + if (! $exists) { + $code = $candidate; + $hash = $candidateHash; + break; + } + } + + if (! $code || ! $hash) { + $code = str_pad((string) random_int(0, $max), $length, '0', STR_PAD_LEFT); + $hash = hash('sha256', $code); + } + + $expiresAt = now()->addMinutes($expiresInMinutes); + + $record = PhotoboothConnectCode::query()->create([ + 'event_id' => $event->getKey(), + 'code_hash' => $hash, + 'expires_at' => $expiresAt, + ]); + + return [ + 'code' => $code, + 'record' => $record, + 'expires_at' => $expiresAt, + ]; + } + + public function redeem(string $code): ?PhotoboothConnectCode + { + $hash = hash('sha256', $code); + + /** @var PhotoboothConnectCode|null $record */ + $record = PhotoboothConnectCode::query() + ->where('code_hash', $hash) + ->whereNull('redeemed_at') + ->where('expires_at', '>=', now()) + ->first(); + + if (! $record) { + return null; + } + + $record->forceFill([ + 'redeemed_at' => now(), + ])->save(); + + return $record; + } +} diff --git a/clients/photobooth-uploader/PhotoboothUploader.sln b/clients/photobooth-uploader/PhotoboothUploader.sln new file mode 100644 index 0000000..fa1a0eb --- /dev/null +++ b/clients/photobooth-uploader/PhotoboothUploader.sln @@ -0,0 +1,18 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.10.35013.3 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PhotoboothUploader", "PhotoboothUploader\PhotoboothUploader.csproj", "{CDF88A75-8B20-4F54-96FC-A640B0D19A10}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {CDF88A75-8B20-4F54-96FC-A640B0D19A10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CDF88A75-8B20-4F54-96FC-A640B0D19A10}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CDF88A75-8B20-4F54-96FC-A640B0D19A10}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CDF88A75-8B20-4F54-96FC-A640B0D19A10}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/clients/photobooth-uploader/PhotoboothUploader/App.xaml b/clients/photobooth-uploader/PhotoboothUploader/App.xaml new file mode 100644 index 0000000..8c2cbd7 --- /dev/null +++ b/clients/photobooth-uploader/PhotoboothUploader/App.xaml @@ -0,0 +1,7 @@ + + + + diff --git a/clients/photobooth-uploader/PhotoboothUploader/App.xaml.cs b/clients/photobooth-uploader/PhotoboothUploader/App.xaml.cs new file mode 100644 index 0000000..aa92a62 --- /dev/null +++ b/clients/photobooth-uploader/PhotoboothUploader/App.xaml.cs @@ -0,0 +1,17 @@ +using Microsoft.UI.Xaml; + +namespace PhotoboothUploader; + +public partial class App : Application +{ + public App() + { + InitializeComponent(); + } + + protected override void OnLaunched(LaunchActivatedEventArgs args) + { + var window = new MainWindow(); + window.Activate(); + } +} diff --git a/clients/photobooth-uploader/PhotoboothUploader/MainWindow.xaml b/clients/photobooth-uploader/PhotoboothUploader/MainWindow.xaml new file mode 100644 index 0000000..046f0b2 --- /dev/null +++ b/clients/photobooth-uploader/PhotoboothUploader/MainWindow.xaml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + ), +})); + +vi.mock('../BottomNav', () => ({ + BottomNav: () =>
, + NavKey: {}, +})); + +vi.mock('../../../context/EventContext', () => ({ + useEventContext: () => ({ + events: [], + activeEvent: { slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} }, + hasMultipleEvents: false, + hasEvents: true, + selectEvent: vi.fn(), + }), +})); + +vi.mock('../../hooks/useMobileNav', () => ({ + useMobileNav: () => ({ go: vi.fn(), slug: 'event-1' }), +})); + +vi.mock('../../hooks/useNotificationsBadge', () => ({ + useNotificationsBadge: () => ({ count: 0 }), +})); + +vi.mock('../../hooks/useOnlineStatus', () => ({ + useOnlineStatus: () => true, +})); + +vi.mock('../../../api', () => ({ + getEvents: vi.fn().mockResolvedValue([]), +})); + +vi.mock('../../lib/tabHistory', () => ({ + setTabHistory: vi.fn(), +})); + +vi.mock('../../lib/photoModerationQueue', () => ({ + loadPhotoQueue: vi.fn(() => []), +})); + +vi.mock('../../lib/queueStatus', () => ({ + countQueuedPhotoActions: vi.fn(() => 0), +})); + +vi.mock('../../theme', () => ({ + useAdminTheme: () => ({ + background: '#FFF8F5', + surface: '#ffffff', + border: '#e5e7eb', + text: '#1f2937', + muted: '#6b7280', + warningBg: '#fff7ed', + warningText: '#92400e', + primary: '#FF5A5F', + danger: '#b91c1c', + shadow: 'rgba(0,0,0,0.12)', + }), +})); + +import { MobileShell } from '../MobileShell'; + +describe('MobileShell', () => { + beforeEach(() => { + window.matchMedia = vi.fn().mockReturnValue({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + }); + + it('renders quick QR as icon-only button', async () => { + await act(async () => { + render( + + +
Body
+
+
+ ); + }); + + expect(screen.getByLabelText('Quick QR')).toBeInTheDocument(); + expect(screen.queryByText('Quick QR')).not.toBeInTheDocument(); + }); + + it('hides the event context on compact headers', async () => { + window.matchMedia = vi.fn().mockReturnValue({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + + await act(async () => { + render( + + +
Body
+
+
+ ); + }); + + expect(screen.queryByText('Test Event')).not.toBeInTheDocument(); + }); +}); diff --git a/resources/js/admin/mobile/hooks/usePackageCheckout.ts b/resources/js/admin/mobile/hooks/usePackageCheckout.ts new file mode 100644 index 0000000..0bcbf2f --- /dev/null +++ b/resources/js/admin/mobile/hooks/usePackageCheckout.ts @@ -0,0 +1,59 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import toast from 'react-hot-toast'; + +import { createTenantPaddleCheckout } from '../../api'; +import { adminPath } from '../../constants'; +import { getApiErrorMessage } from '../../lib/apiError'; +import { storePendingCheckout } from '../lib/billingCheckout'; + +export function usePackageCheckout(): { + busy: boolean; + startCheckout: (packageId: number) => Promise; +} { + const { t } = useTranslation('management'); + const [busy, setBusy] = React.useState(false); + + const startCheckout = React.useCallback( + async (packageId: number) => { + if (busy) { + return; + } + setBusy(true); + try { + if (typeof window === 'undefined') { + throw new Error('Checkout is only available in the browser.'); + } + + const billingUrl = new URL(adminPath('/mobile/billing'), window.location.origin); + const successUrl = new URL(billingUrl); + successUrl.searchParams.set('checkout', 'success'); + successUrl.searchParams.set('package_id', String(packageId)); + const cancelUrl = new URL(billingUrl); + cancelUrl.searchParams.set('checkout', 'cancel'); + cancelUrl.searchParams.set('package_id', String(packageId)); + + const { checkout_url, checkout_session_id } = await createTenantPaddleCheckout(packageId, { + success_url: successUrl.toString(), + return_url: cancelUrl.toString(), + }); + + if (checkout_session_id) { + storePendingCheckout({ + packageId, + checkoutSessionId: checkout_session_id, + startedAt: Date.now(), + }); + } + + window.location.href = checkout_url; + } catch (err) { + toast.error(getApiErrorMessage(err, t('shop.errors.checkout', 'Checkout failed'))); + setBusy(false); + } + }, + [busy, t], + ); + + return { busy, startCheckout }; +} diff --git a/resources/js/admin/mobile/lib/analytics.ts b/resources/js/admin/mobile/lib/analytics.ts new file mode 100644 index 0000000..9af13b9 --- /dev/null +++ b/resources/js/admin/mobile/lib/analytics.ts @@ -0,0 +1,28 @@ +export function resolveMaxCount(values: number[]): number { + if (!Array.isArray(values) || values.length === 0) { + return 1; + } + + return Math.max(...values, 1); +} + +export function resolveTimelineHours(timestamps: string[], fallbackHours = 12): number { + if (!Array.isArray(timestamps) || timestamps.length < 2) { + return fallbackHours; + } + + const times = timestamps + .map((value) => new Date(value).getTime()) + .filter((value) => Number.isFinite(value)); + + if (times.length < 2) { + return fallbackHours; + } + + const min = Math.min(...times); + const max = Math.max(...times); + const diff = Math.max(0, max - min); + const hours = diff / (1000 * 60 * 60); + + return Math.max(1, Math.round(hours)); +} diff --git a/resources/js/admin/mobile/lib/billingCheckout.ts b/resources/js/admin/mobile/lib/billingCheckout.ts new file mode 100644 index 0000000..e8d483d --- /dev/null +++ b/resources/js/admin/mobile/lib/billingCheckout.ts @@ -0,0 +1,82 @@ +export type PendingCheckout = { + packageId: number | null; + checkoutSessionId?: string | null; + startedAt: number; +}; + +export const PENDING_CHECKOUT_TTL_MS = 1000 * 60 * 30; +export const CHECKOUT_STORAGE_KEY = 'admin.billing.checkout.pending.v1'; + +export function isCheckoutExpired( + pending: PendingCheckout, + now = Date.now(), + ttl = PENDING_CHECKOUT_TTL_MS, +): boolean { + return now - pending.startedAt > ttl; +} + +export function loadPendingCheckout( + now = Date.now(), + ttl = PENDING_CHECKOUT_TTL_MS, +): PendingCheckout | null { + if (typeof window === 'undefined') { + return null; + } + try { + const raw = window.sessionStorage.getItem(CHECKOUT_STORAGE_KEY); + if (! raw) { + return null; + } + const parsed = JSON.parse(raw) as PendingCheckout; + if (typeof parsed?.startedAt !== 'number') { + return null; + } + const packageId = + typeof parsed.packageId === 'number' && Number.isFinite(parsed.packageId) + ? parsed.packageId + : null; + const checkoutSessionId = typeof parsed.checkoutSessionId === 'string' ? parsed.checkoutSessionId : null; + if (now - parsed.startedAt > ttl) { + return null; + } + return { + packageId, + checkoutSessionId, + startedAt: parsed.startedAt, + }; + } catch { + return null; + } +} + +export function storePendingCheckout(next: PendingCheckout | null): void { + if (typeof window === 'undefined') { + return; + } + try { + if (! next) { + window.sessionStorage.removeItem(CHECKOUT_STORAGE_KEY); + } else { + window.sessionStorage.setItem(CHECKOUT_STORAGE_KEY, JSON.stringify(next)); + } + } catch { + // Ignore storage errors. + } +} + +export function shouldClearPendingCheckout( + pending: PendingCheckout, + activePackageId: number | null, + now = Date.now(), + ttl = PENDING_CHECKOUT_TTL_MS, +): boolean { + if (isCheckoutExpired(pending, now, ttl)) { + return true; + } + + if (pending.packageId && activePackageId && pending.packageId === activePackageId) { + return true; + } + + return false; +} diff --git a/resources/js/admin/mobile/lib/packageShop.ts b/resources/js/admin/mobile/lib/packageShop.ts new file mode 100644 index 0000000..b07ec7d --- /dev/null +++ b/resources/js/admin/mobile/lib/packageShop.ts @@ -0,0 +1,146 @@ +import type { Package } from '../../api'; + +type PackageChange = { + isUpgrade: boolean; + isDowngrade: boolean; +}; + +export type PackageComparisonRow = + | { + id: string; + type: 'limit'; + limitKey: 'max_photos' | 'max_guests' | 'gallery_days'; + } + | { + id: string; + type: 'feature'; + featureKey: string; + }; + +function normalizePackageFeatures(pkg: Package | null): string[] { + if (!pkg?.features) { + return []; + } + + if (Array.isArray(pkg.features)) { + return pkg.features.filter((feature): feature is string => typeof feature === 'string' && feature.trim().length > 0); + } + + if (typeof pkg.features === 'object') { + return Object.entries(pkg.features) + .filter(([, enabled]) => enabled) + .map(([key]) => key); + } + + return []; +} + +export function getEnabledPackageFeatures(pkg: Package): string[] { + return normalizePackageFeatures(pkg); +} + +function collectFeatures(pkg: Package | null): Set { + return new Set(normalizePackageFeatures(pkg)); +} + +function compareLimit(candidate: number | null, active: number | null): number { + if (active === null) { + return candidate === null ? 0 : -1; + } + + if (candidate === null) { + return 1; + } + + if (candidate > active) return 1; + if (candidate < active) return -1; + return 0; +} + +export function classifyPackageChange(pkg: Package, active: Package | null): PackageChange { + if (!active) { + return { isUpgrade: false, isDowngrade: false }; + } + + const activeFeatures = collectFeatures(active); + const candidateFeatures = collectFeatures(pkg); + + const hasFeatureUpgrade = Array.from(candidateFeatures).some((feature) => !activeFeatures.has(feature)); + const hasFeatureDowngrade = Array.from(activeFeatures).some((feature) => !candidateFeatures.has(feature)); + + const limitKeys: Array = ['max_photos', 'max_guests', 'gallery_days']; + let hasLimitUpgrade = false; + let hasLimitDowngrade = false; + + limitKeys.forEach((key) => { + const candidateLimit = pkg[key] ?? null; + const activeLimit = active[key] ?? null; + const delta = compareLimit(candidateLimit, activeLimit); + if (delta > 0) { + hasLimitUpgrade = true; + } else if (delta < 0) { + hasLimitDowngrade = true; + } + }); + + const hasUpgrade = hasFeatureUpgrade || hasLimitUpgrade; + const hasDowngrade = hasFeatureDowngrade || hasLimitDowngrade; + + if (hasUpgrade && !hasDowngrade) { + return { isUpgrade: true, isDowngrade: false }; + } + + if (hasDowngrade) { + return { isUpgrade: false, isDowngrade: true }; + } + + return { isUpgrade: false, isDowngrade: false }; +} + +export function selectRecommendedPackageId( + packages: Package[], + feature: string | null, + activePackage: Package | null +): number | null { + if (!feature) { + return null; + } + + const candidates = packages.filter((pkg) => normalizePackageFeatures(pkg).includes(feature)); + if (candidates.length === 0) { + return null; + } + + const upgrades = candidates.filter((pkg) => classifyPackageChange(pkg, activePackage).isUpgrade); + const pool = upgrades.length ? upgrades : candidates; + const sorted = [...pool].sort((a, b) => a.price - b.price); + + return sorted[0]?.id ?? null; +} + +export function buildPackageComparisonRows(packages: Package[]): PackageComparisonRow[] { + const limitRows: PackageComparisonRow[] = [ + { id: 'limit.max_photos', type: 'limit', limitKey: 'max_photos' }, + { id: 'limit.max_guests', type: 'limit', limitKey: 'max_guests' }, + { id: 'limit.gallery_days', type: 'limit', limitKey: 'gallery_days' }, + ]; + + const featureKeys = new Set(); + packages.forEach((pkg) => { + normalizePackageFeatures(pkg).forEach((key) => { + if (key !== 'photos') { + featureKeys.add(key); + } + }); + }); + + const featureRows = Array.from(featureKeys) + .sort((a, b) => a.localeCompare(b)) + .map((featureKey) => ({ + id: `feature.${featureKey}`, + type: 'feature' as const, + featureKey, + })); + + return [...limitRows, ...featureRows]; +} diff --git a/resources/js/admin/mobile/lib/packageSummary.test.ts b/resources/js/admin/mobile/lib/packageSummary.test.ts index a2d687c..e5c2525 100644 --- a/resources/js/admin/mobile/lib/packageSummary.test.ts +++ b/resources/js/admin/mobile/lib/packageSummary.test.ts @@ -15,7 +15,8 @@ const t = (key: string, options?: Record | string) => { return template .replace('{{used}}', String(options?.used ?? '{{used}}')) .replace('{{limit}}', String(options?.limit ?? '{{limit}}')) - .replace('{{remaining}}', String(options?.remaining ?? '{{remaining}}')); + .replace('{{remaining}}', String(options?.remaining ?? '{{remaining}}')) + .replace('{{count}}', String(options?.count ?? '{{count}}')); }; describe('packageSummary helpers', () => { @@ -53,6 +54,12 @@ describe('packageSummary helpers', () => { expect(result[0].value).toBe('30 of 120 remaining'); }); + it('falls back to remaining count when remaining exceeds limit', () => { + const result = getPackageLimitEntries({ max_photos: 120, remaining_photos: 180 }, t); + + expect(result[0].value).toBe('Remaining 180'); + }); + it('formats event usage copy', () => { const result = formatEventUsage(3, 10, t); diff --git a/resources/js/admin/mobile/lib/packageSummary.ts b/resources/js/admin/mobile/lib/packageSummary.ts index 75c0a03..8091fbc 100644 --- a/resources/js/admin/mobile/lib/packageSummary.ts +++ b/resources/js/admin/mobile/lib/packageSummary.ts @@ -138,6 +138,12 @@ const formatLimitWithRemaining = (limit: number | null, remaining: number | null if (remaining !== null && remaining >= 0) { const normalizedRemaining = Number.isFinite(remaining) ? Math.max(0, Math.round(remaining)) : remaining; + if (normalizedRemaining > limit) { + return t('mobileBilling.usage.remaining', { + count: normalizedRemaining, + defaultValue: 'Remaining {{count}}', + }); + } return t('mobileBilling.usage.remainingOf', { remaining: normalizedRemaining, limit, diff --git a/resources/js/guest/components/Header.tsx b/resources/js/guest/components/Header.tsx index 224ca59..20b136d 100644 --- a/resources/js/guest/components/Header.tsx +++ b/resources/js/guest/components/Header.tsx @@ -27,7 +27,6 @@ import { SettingsSheet } from './settings-sheet'; import { useTranslation, type TranslateFn } from '../i18n/useTranslation'; import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext'; import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext'; -import { useGuestTaskProgress, TASK_BADGE_TARGET } from '../hooks/useGuestTaskProgress'; import { usePushSubscription } from '../hooks/usePushSubscription'; import { getContrastingTextColor, relativeLuminance } from '../lib/color'; import { isTaskModeEnabled } from '../lib/engagement'; @@ -151,7 +150,6 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string const { event, status } = useEventData(); const notificationCenter = useOptionalNotificationCenter(); const [notificationsOpen, setNotificationsOpen] = React.useState(false); - const taskProgress = useGuestTaskProgress(eventToken); const tasksEnabled = isTaskModeEnabled(event); const panelRef = React.useRef(null); const notificationButtonRef = React.useRef(null); @@ -258,7 +256,6 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string onToggle={() => setNotificationsOpen((prev) => !prev)} panelRef={panelRef} buttonRef={notificationButtonRef} - taskProgress={tasksEnabled && taskProgress?.hydrated ? taskProgress : undefined} t={t} /> )} @@ -285,18 +282,14 @@ type NotificationButtonProps = { onToggle: () => void; panelRef: React.RefObject; buttonRef: React.RefObject; - taskProgress?: ReturnType; t: TranslateFn; }; type PushState = ReturnType; -function NotificationButton({ center, eventToken, open, onToggle, panelRef, buttonRef, taskProgress, t }: NotificationButtonProps) { - const badgeCount = center.unreadCount + center.pendingCount + center.queueCount; - const progressRatio = taskProgress - ? Math.min(1, taskProgress.completedCount / TASK_BADGE_TARGET) - : 0; - const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'status'>(center.unreadCount > 0 ? 'unread' : 'all'); +function NotificationButton({ center, eventToken, open, onToggle, panelRef, buttonRef, t }: NotificationButtonProps) { + const badgeCount = center.unreadCount; + const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'uploads'>(center.unreadCount > 0 ? 'unread' : 'all'); const [scopeFilter, setScopeFilter] = React.useState<'all' | 'tips' | 'general'>('all'); const pushState = usePushSubscription(eventToken); @@ -321,7 +314,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt case 'unread': base = unreadNotifications; break; - case 'status': + case 'uploads': base = uploadNotifications; break; default: @@ -331,7 +324,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt }, [activeTab, center.notifications, unreadNotifications, uploadNotifications]); const scopedNotifications = React.useMemo(() => { - if (scopeFilter === 'all') { + if (activeTab === 'uploads' || scopeFilter === 'all') { return filteredNotifications; } return filteredNotifications.filter((item) => { @@ -365,10 +358,10 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt >
-

{t('header.notifications.title', 'Benachrichtigungen')}

+

{t('header.notifications.title', 'Updates')}

{center.unreadCount > 0 - ? t('header.notifications.unread', { defaultValue: '{{count}} neu', count: center.unreadCount }) + ? t('header.notifications.unread', { defaultValue: '{count} neu', count: center.unreadCount }) : t('header.notifications.allRead', 'Alles gelesen')}

@@ -384,67 +377,43 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
setActiveTab(next as typeof activeTab)} /> -
-
- {( - [ - { 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) => ( + + ))} +
-
-
- {center.loading ? ( - - ) : scopedNotifications.length === 0 ? ( - - ) : ( - scopedNotifications.map((item) => ( - center.markAsRead(item.id)} - onDismiss={() => center.dismiss(item.id)} - t={t} - /> - )) - )} -
- {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()); + } +}