From 7025418d9e69128df66325c8418e7e02a72b58d3 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 4 Feb 2026 14:35:52 +0100 Subject: [PATCH] Adjust join token expiry for event dates --- .../Api/Tenant/EventController.php | 9 +- .../Api/Tenant/EventJoinTokenController.php | 15 +++- app/Models/Event.php | 8 ++ app/Services/EventJoinTokenService.php | 87 +++++++++++++++++-- .../Tenant/EventJoinTokenTtlPolicyTest.php | 71 ++++++++++++++- 5 files changed, 179 insertions(+), 11 deletions(-) diff --git a/app/Http/Controllers/Api/Tenant/EventController.php b/app/Http/Controllers/Api/Tenant/EventController.php index 83d67175..1591c22d 100644 --- a/app/Http/Controllers/Api/Tenant/EventController.php +++ b/app/Http/Controllers/Api/Tenant/EventController.php @@ -882,9 +882,16 @@ class EventController extends Controller ); } + $minimumExpiry = $this->joinTokenService->minimumExpiryForEvent($event); + $expiresAtRules = ['nullable', 'date', 'after:now']; + + if ($minimumExpiry) { + $expiresAtRules[] = 'after_or_equal:'.$minimumExpiry->toDateTimeString(); + } + $validated = $request->validate([ 'label' => ['nullable', 'string', 'max:255'], - 'expires_at' => ['nullable', 'date', 'after:now'], + 'expires_at' => $expiresAtRules, 'usage_limit' => ['nullable', 'integer', 'min:1'], ]); diff --git a/app/Http/Controllers/Api/Tenant/EventJoinTokenController.php b/app/Http/Controllers/Api/Tenant/EventJoinTokenController.php index f532eba9..dc8b0198 100644 --- a/app/Http/Controllers/Api/Tenant/EventJoinTokenController.php +++ b/app/Http/Controllers/Api/Tenant/EventJoinTokenController.php @@ -33,7 +33,7 @@ class EventJoinTokenController extends Controller { $this->authorizeEvent($request, $event, 'join-tokens:manage'); - $validated = $this->validatePayload($request); + $validated = $this->validatePayload($request, $event); $token = $this->joinTokenService->createToken($event, array_merge($validated, [ 'created_by' => Auth::id(), @@ -52,7 +52,7 @@ class EventJoinTokenController extends Controller abort(404); } - $validated = $this->validatePayload($request, true); + $validated = $this->validatePayload($request, $event, true); $payload = []; @@ -115,11 +115,18 @@ class EventJoinTokenController extends Controller } } - private function validatePayload(Request $request, bool $partial = false): array + private function validatePayload(Request $request, Event $event, bool $partial = false): array { + $minimumExpiry = $this->joinTokenService->minimumExpiryForEvent($event); + $expiresAtRules = [$partial ? 'nullable' : 'sometimes', 'nullable', 'date', 'after:now']; + + if ($minimumExpiry) { + $expiresAtRules[] = 'after_or_equal:'.$minimumExpiry->toDateTimeString(); + } + $rules = [ 'label' => [$partial ? 'nullable' : 'sometimes', 'string', 'max:255'], - 'expires_at' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'date', 'after:now'], + 'expires_at' => $expiresAtRules, 'usage_limit' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'integer', 'min:1'], 'metadata' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'array'], 'metadata.layout_customization' => ['nullable', 'array'], diff --git a/app/Models/Event.php b/app/Models/Event.php index f2076eae..1edce659 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -40,6 +40,14 @@ class Event extends Model ], ]); }); + + static::updated(function (self $event): void { + if (! $event->wasChanged('date')) { + return; + } + + app(EventJoinTokenService::class)->extendExpiryForEvent($event); + }); } public function storageAssignments(): HasMany diff --git a/app/Services/EventJoinTokenService.php b/app/Services/EventJoinTokenService.php index 7d862c10..fec78ebd 100644 --- a/app/Services/EventJoinTokenService.php +++ b/app/Services/EventJoinTokenService.php @@ -27,14 +27,21 @@ class EventJoinTokenService ]; if ($expiresAt = Arr::get($attributes, 'expires_at')) { - $payload['expires_at'] = $expiresAt instanceof Carbon + $resolvedExpiry = $expiresAt instanceof Carbon ? $expiresAt : Carbon::parse($expiresAt); - } else { - $ttlHours = (int) (GuestPolicySetting::current()->join_token_ttl_hours ?? 0); - if ($ttlHours > 0) { - $payload['expires_at'] = now()->addHours($ttlHours); + $minimumExpiry = $this->minimumExpiryForEvent($event); + if ($minimumExpiry && $resolvedExpiry->lessThan($minimumExpiry)) { + $resolvedExpiry = $minimumExpiry; + } + + $payload['expires_at'] = $resolvedExpiry; + } else { + $defaultExpiry = $this->defaultExpiryForEvent($event); + + if ($defaultExpiry) { + $payload['expires_at'] = $defaultExpiry; } } @@ -48,6 +55,21 @@ class EventJoinTokenService }); } + public function extendExpiryForEvent(Event $event): int + { + $minimumExpiry = $this->minimumExpiryForEvent($event); + + if (! $minimumExpiry) { + return 0; + } + + return $event->joinTokens() + ->whereNull('revoked_at') + ->whereNotNull('expires_at') + ->where('expires_at', '<', $minimumExpiry) + ->update(['expires_at' => $minimumExpiry]); + } + public function revoke(EventJoinToken $joinToken, ?string $reason = null): EventJoinToken { unset($joinToken->plain_token); @@ -162,6 +184,61 @@ class EventJoinTokenService return hash('sha256', $token); } + public function minimumExpiryForEvent(Event $event): ?Carbon + { + $eventDate = $this->resolveEventDate($event); + + if (! $eventDate) { + return null; + } + + $ttlHours = $this->defaultTtlHours(); + + if ($ttlHours > 0) { + return $eventDate->copy()->addHours($ttlHours); + } + + return $eventDate; + } + + private function defaultExpiryForEvent(Event $event): ?Carbon + { + $ttlHours = $this->defaultTtlHours(); + + if ($ttlHours <= 0) { + return null; + } + + $defaultExpiry = now()->addHours($ttlHours); + $minimumExpiry = $this->minimumExpiryForEvent($event); + + if ($minimumExpiry && $minimumExpiry->greaterThan($defaultExpiry)) { + $defaultExpiry = $minimumExpiry; + } + + return $defaultExpiry; + } + + private function resolveEventDate(Event $event): ?Carbon + { + if (! ($event->date instanceof Carbon)) { + return null; + } + + $eventDate = $event->date->copy(); + + if ($eventDate->format('H:i:s') === '00:00:00') { + $eventDate = $eventDate->endOfDay(); + } + + return $eventDate; + } + + private function defaultTtlHours(): int + { + return (int) (GuestPolicySetting::current()->join_token_ttl_hours ?? 0); + } + private function normalizeDeviceId(?string $deviceId): ?string { if (! is_string($deviceId) || trim($deviceId) === '') { diff --git a/tests/Feature/Tenant/EventJoinTokenTtlPolicyTest.php b/tests/Feature/Tenant/EventJoinTokenTtlPolicyTest.php index bf3621b9..fee9f461 100644 --- a/tests/Feature/Tenant/EventJoinTokenTtlPolicyTest.php +++ b/tests/Feature/Tenant/EventJoinTokenTtlPolicyTest.php @@ -11,21 +11,90 @@ class EventJoinTokenTtlPolicyTest extends TenantTestCase { Carbon::setTestNow(Carbon::parse('2026-01-05 12:00:00')); + $eventDate = Carbon::parse('2026-06-15'); + $event = Event::factory() ->for($this->tenant) ->create([ 'name' => ['de' => 'Token TTL Test', 'en' => 'Token TTL Test'], 'slug' => 'token-ttl-test', + 'date' => $eventDate, ]); $response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/join-tokens"); $response->assertCreated(); - $expectedExpiry = now()->addDays(7)->toIso8601String(); + $expectedExpiry = $eventDate->copy() + ->endOfDay() + ->addDays(7) + ->toIso8601String(); $this->assertSame($expectedExpiry, $response->json('data.expires_at')); Carbon::setTestNow(); } + + public function test_join_tokens_extend_when_event_date_moves_forward(): void + { + Carbon::setTestNow(Carbon::parse('2026-01-05 12:00:00')); + + $event = Event::factory() + ->for($this->tenant) + ->create([ + 'name' => ['de' => 'Token Update Test', 'en' => 'Token Update Test'], + 'slug' => 'token-update-test', + 'date' => Carbon::parse('2026-01-10'), + ]); + + $token = $event->joinTokens()->firstOrFail(); + + $newDate = Carbon::parse('2026-04-20'); + $response = $this->authenticatedRequest('PUT', "/api/v1/tenant/events/{$event->slug}", [ + 'event_date' => $newDate->toDateString(), + ]); + + $response->assertOk(); + + $token->refresh(); + + $expectedExpiry = $newDate->copy() + ->endOfDay() + ->addDays(7); + + $this->assertSame( + $expectedExpiry->toIso8601String(), + $token->expires_at?->toIso8601String() + ); + + Carbon::setTestNow(); + } + + public function test_join_token_expiry_must_not_be_before_event_minimum(): void + { + Carbon::setTestNow(Carbon::parse('2026-01-05 12:00:00')); + + $eventDate = Carbon::parse('2026-06-15'); + + $event = Event::factory() + ->for($this->tenant) + ->create([ + 'name' => ['de' => 'Token Expiry Guard', 'en' => 'Token Expiry Guard'], + 'slug' => 'token-expiry-guard', + 'date' => $eventDate, + ]); + + $invalidExpiry = $eventDate->copy() + ->endOfDay() + ->addDay(); + + $response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/join-tokens", [ + 'expires_at' => $invalidExpiry->toDateTimeString(), + ]); + + $response->assertUnprocessable(); + $response->assertInvalid(['expires_at']); + + Carbon::setTestNow(); + } }