diff --git a/.beads/last-touched b/.beads/last-touched index 4d1f588..7c0dc08 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -fotospiel-app-29r +fotospiel-app-9em diff --git a/.gitignore b/.gitignore index 8903637..0349bda 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,3 @@ 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 ecbcfb2..bf803ec 100644 --- a/app/Http/Controllers/Api/PackageController.php +++ b/app/Http/Controllers/Api/PackageController.php @@ -3,12 +3,9 @@ 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; @@ -17,10 +14,7 @@ use Illuminate\Validation\ValidationException; class PackageController extends Controller { - public function __construct( - private readonly PaddleCheckoutService $paddleCheckout, - private readonly CheckoutSessionService $sessions, - ) {} + public function __construct(private readonly PaddleCheckoutService $paddleCheckout) {} public function index(Request $request): JsonResponse { @@ -171,82 +165,23 @@ 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); - $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, - ]); + return response()->json($checkout); } 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 deleted file mode 100644 index aef8377..0000000 --- a/app/Http/Controllers/Api/PhotoboothConnectController.php +++ /dev/null @@ -1,45 +0,0 @@ -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 5429342..25c8ebf 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-admin')) { + if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant:write')) { return ApiError::response( 'insufficient_scope', 'Insufficient Scopes', 'You are not allowed to moderate photos for this event.', Response::HTTP_FORBIDDEN, - ['required_scope' => 'tenant-admin'] + ['required_scope' => 'tenant:write'] ); } @@ -823,11 +823,6 @@ 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 deleted file mode 100644 index f473b21..0000000 --- a/app/Http/Controllers/Api/Tenant/PhotoboothConnectCodeController.php +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index d99296d..0000000 --- a/app/Http/Requests/Photobooth/PhotoboothConnectRedeemRequest.php +++ /dev/null @@ -1,37 +0,0 @@ -|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 deleted file mode 100644 index 6651e4c..0000000 --- a/app/Http/Requests/Tenant/PhotoboothConnectCodeStoreRequest.php +++ /dev/null @@ -1,28 +0,0 @@ -|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 1ba077b..d5cb599 100644 --- a/app/Listeners/GuestNotifications/SendPhotoUploadedNotification.php +++ b/app/Listeners/GuestNotifications/SendPhotoUploadedNotification.php @@ -5,19 +5,11 @@ 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 */ @@ -33,20 +25,7 @@ class SendPhotoUploadedNotification ? sprintf('%s hat gerade ein Foto gemacht 🎉', $guestLabel) : 'Es gibt neue Fotos!'; - $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( + $this->notifications->createNotification( $event->event, GuestNotificationType::PHOTO_ACTIVITY, $title, @@ -55,15 +34,11 @@ 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); } @@ -112,94 +87,4 @@ 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 deleted file mode 100644 index b6ef33c..0000000 --- a/app/Models/PhotoboothConnectCode.php +++ /dev/null @@ -1,25 +0,0 @@ - */ - 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 a235fea..64ede0a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -162,10 +162,6 @@ 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 8bbd94b..ed27b9b 100644 --- a/app/Services/GuestNotificationService.php +++ b/app/Services/GuestNotificationService.php @@ -126,36 +126,6 @@ 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 = [ @@ -172,9 +142,6 @@ 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 deleted file mode 100644 index 55ad823..0000000 --- a/app/Services/Photobooth/PhotoboothConnectCodeService.php +++ /dev/null @@ -1,80 +0,0 @@ -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 deleted file mode 100644 index fa1a0eb..0000000 --- a/clients/photobooth-uploader/PhotoboothUploader.sln +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 8c2cbd7..0000000 --- a/clients/photobooth-uploader/PhotoboothUploader/App.xaml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/clients/photobooth-uploader/PhotoboothUploader/App.xaml.cs b/clients/photobooth-uploader/PhotoboothUploader/App.xaml.cs deleted file mode 100644 index aa92a62..0000000 --- a/clients/photobooth-uploader/PhotoboothUploader/App.xaml.cs +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 046f0b2..0000000 --- a/clients/photobooth-uploader/PhotoboothUploader/MainWindow.xaml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - ), -})); - -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 deleted file mode 100644 index 0bcbf2f..0000000 --- a/resources/js/admin/mobile/hooks/usePackageCheckout.ts +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index 9af13b9..0000000 --- a/resources/js/admin/mobile/lib/analytics.ts +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index e8d483d..0000000 --- a/resources/js/admin/mobile/lib/billingCheckout.ts +++ /dev/null @@ -1,82 +0,0 @@ -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 deleted file mode 100644 index b07ec7d..0000000 --- a/resources/js/admin/mobile/lib/packageShop.ts +++ /dev/null @@ -1,146 +0,0 @@ -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 e5c2525..a2d687c 100644 --- a/resources/js/admin/mobile/lib/packageSummary.test.ts +++ b/resources/js/admin/mobile/lib/packageSummary.test.ts @@ -15,8 +15,7 @@ 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('{{count}}', String(options?.count ?? '{{count}}')); + .replace('{{remaining}}', String(options?.remaining ?? '{{remaining}}')); }; describe('packageSummary helpers', () => { @@ -54,12 +53,6 @@ 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 8091fbc..75c0a03 100644 --- a/resources/js/admin/mobile/lib/packageSummary.ts +++ b/resources/js/admin/mobile/lib/packageSummary.ts @@ -138,12 +138,6 @@ 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 20b136d..224ca59 100644 --- a/resources/js/guest/components/Header.tsx +++ b/resources/js/guest/components/Header.tsx @@ -27,6 +27,7 @@ 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'; @@ -150,6 +151,7 @@ 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); @@ -256,6 +258,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string onToggle={() => setNotificationsOpen((prev) => !prev)} panelRef={panelRef} buttonRef={notificationButtonRef} + taskProgress={tasksEnabled && taskProgress?.hydrated ? taskProgress : undefined} t={t} /> )} @@ -282,14 +285,18 @@ 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, t }: NotificationButtonProps) { - const badgeCount = center.unreadCount; - const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'uploads'>(center.unreadCount > 0 ? 'unread' : 'all'); +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'); const [scopeFilter, setScopeFilter] = React.useState<'all' | 'tips' | 'general'>('all'); const pushState = usePushSubscription(eventToken); @@ -314,7 +321,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt case 'unread': base = unreadNotifications; break; - case 'uploads': + case 'status': base = uploadNotifications; break; default: @@ -324,7 +331,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt }, [activeTab, center.notifications, unreadNotifications, uploadNotifications]); const scopedNotifications = React.useMemo(() => { - if (activeTab === 'uploads' || scopeFilter === 'all') { + if (scopeFilter === 'all') { return filteredNotifications; } return filteredNotifications.filter((item) => { @@ -358,10 +365,10 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt >
-

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

+

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

{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')}

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

{t('header.notifications.badgeLabel', 'Badge-Fortschritt')}

+

+ {taskProgress.completedCount}/{TASK_BADGE_TARGET} +

+
+ + {t('header.notifications.tasksCta', 'Weiter')} + +
+
+
- )) - )} -
+
+
+ )} ({ queueItems: [], queueCount: 0, pendingCount: 0, + totalCount: 0, loading: false, pendingLoading: false, refresh: vi.fn(), @@ -96,10 +97,10 @@ describe('Header notifications toggle', () => { const bellButton = screen.getByLabelText('Benachrichtigungen anzeigen'); fireEvent.click(bellButton); - expect(screen.getByText('Updates')).toBeInTheDocument(); + expect(screen.getByText('Benachrichtigungen')).toBeInTheDocument(); fireEvent.click(bellButton); - expect(screen.queryByText('Updates')).not.toBeInTheDocument(); + expect(screen.queryByText('Benachrichtigungen')).not.toBeInTheDocument(); }); }); diff --git a/resources/js/guest/context/NotificationCenterContext.tsx b/resources/js/guest/context/NotificationCenterContext.tsx index ef023c0..817b8f0 100644 --- a/resources/js/guest/context/NotificationCenterContext.tsx +++ b/resources/js/guest/context/NotificationCenterContext.tsx @@ -16,6 +16,7 @@ export type NotificationCenterValue = { queueItems: QueueItem[]; queueCount: number; pendingCount: number; + totalCount: number; loading: boolean; pendingLoading: boolean; refresh: () => Promise; @@ -263,9 +264,11 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke }, [loadNotifications, refreshQueue, loadPendingUploads]); const loading = loadingNotifications || queueLoading || pendingLoading; + const totalCount = unreadCount + queueCount + pendingCount; + React.useEffect(() => { - void updateAppBadge(unreadCount); - }, [unreadCount]); + void updateAppBadge(totalCount); + }, [totalCount]); const value: NotificationCenterValue = { notifications, @@ -273,6 +276,7 @@ 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 83b15ec..6a8291f 100644 --- a/resources/js/guest/i18n/messages.ts +++ b/resources/js/guest/i18n/messages.ts @@ -42,13 +42,7 @@ export const messages: Record = { }, helpGallery: 'Hilfe zu Galerie & Teilen', notifications: { - title: 'Updates', - unread: '{count} neu', - allRead: 'Alles gelesen', - tabUnread: 'Nachrichten', - tabUploads: 'Uploads', - tabAll: 'Alle Updates', - emptyStatus: 'Keine Upload-Hinweise oder Wartungen aktiv.', + tabStatus: 'Upload-Status', }, }, liveShowPlayer: { @@ -780,13 +774,7 @@ export const messages: Record = { }, helpGallery: 'Help: Gallery & sharing', notifications: { - title: 'Updates', - unread: '{count} new', - allRead: 'All read', - tabUnread: 'Messages', - tabUploads: 'Uploads', - tabAll: 'All updates', - emptyStatus: 'No upload status or maintenance active.', + tabStatus: 'Upload status', }, }, liveShowPlayer: { diff --git a/routes/api.php b/routes/api.php index ffa82fc..e766285 100644 --- a/routes/api.php +++ b/routes/api.php @@ -6,7 +6,6 @@ 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; @@ -25,7 +24,6 @@ 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; @@ -155,9 +153,6 @@ 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') @@ -268,8 +263,6 @@ 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']) @@ -360,8 +353,6 @@ 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 deleted file mode 100644 index 68bdb6b..0000000 --- a/tests/Feature/Photobooth/PhotoboothConnectCodeTest.php +++ /dev/null @@ -1,100 +0,0 @@ -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 deleted file mode 100644 index 567125e..0000000 --- a/tests/Feature/Tenant/PhotoModerationControllerTest.php +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 77e38de..0000000 --- a/tests/Feature/Tenant/TenantCheckoutSessionStatusTest.php +++ /dev/null @@ -1,46 +0,0 @@ -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 76fc651..4156b20 100644 --- a/tests/Feature/Tenant/TenantPaddleCheckoutTest.php +++ b/tests/Feature/Tenant/TenantPaddleCheckoutTest.php @@ -29,10 +29,7 @@ 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('metadata', $payload) - && is_array($payload['metadata']) - && ! empty($payload['metadata']['checkout_session_id']); + && array_key_exists('return_url', $payload); }) ->andReturn([ 'checkout_url' => 'https://checkout.paddle.test/checkout/123', @@ -45,8 +42,7 @@ class TenantPaddleCheckoutTest extends TenantTestCase ]); $response->assertOk() - ->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123') - ->assertJsonStructure(['checkout_session_id']); + ->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123'); } public function test_paddle_checkout_requires_paddle_price_id(): void diff --git a/tests/Unit/RateLimitConfigTest.php b/tests/Unit/RateLimitConfigTest.php deleted file mode 100644 index 5f4a14d..0000000 --- a/tests/Unit/RateLimitConfigTest.php +++ /dev/null @@ -1,67 +0,0 @@ - '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 deleted file mode 100644 index 2fc38bf..0000000 --- a/tests/Unit/SendPhotoUploadedNotificationTest.php +++ /dev/null @@ -1,144 +0,0 @@ -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()); - } -}