diff --git a/app/Http/Controllers/Meet/JoinController.php b/app/Http/Controllers/Meet/JoinController.php index 601f9a1..ac475f5 100644 --- a/app/Http/Controllers/Meet/JoinController.php +++ b/app/Http/Controllers/Meet/JoinController.php @@ -6,11 +6,13 @@ use App\Http\Controllers\Controller; use App\Models\Participant; use App\Models\Room; use App\Models\Session; +use App\Services\Integrations\EventsClient; use App\Services\Meet\ConferenceService; use App\Services\Meet\InvitationService; use App\Services\Meet\SecurityPolicyService; use App\Services\Meet\SessionService; use App\Services\Meet\SpaceService; +use App\Support\EventsRegistrationSession; use App\Support\EventsSourceLink; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; @@ -25,6 +27,7 @@ class JoinController extends Controller protected SecurityPolicyService $security, protected SpaceService $spaces, protected ConferenceService $conferences, + protected EventsClient $events, ) {} public function show(Request $request, Room $room): View|RedirectResponse @@ -74,11 +77,38 @@ class JoinController extends Controller } } - return view('meet.join.show', [ - 'room' => $room, - 'organization' => $room->organization, - 'user' => $request->user(), - 'isWebinar' => $room->isWebinar(), + $badge = trim((string) $request->query('badge', '')); + if ($badge !== '' && $this->requiresEventsRegistration($room, $request)) { + if ($this->verifyAndStoreBadge($request, $room, $badge)) { + return redirect()->route('meet.join', $room); + } + + return $this->gateView($request, $room)->withErrors([ + 'badge_code' => 'Badge code not found or registration is not confirmed.', + ]); + } + + if ($this->requiresEventsRegistration($room, $request)) { + return $this->gateView($request, $room); + } + + return $this->displayNameView($request, $room); + } + + public function verifyBadge(Request $request, Room $room): RedirectResponse + { + if ($room->passcode && ! $request->session()->get("meet.passcode.{$room->uuid}")) { + return redirect()->route('meet.join', $room); + } + + $request->validate(['badge_code' => ['required', 'string', 'max:16']]); + + if ($this->verifyAndStoreBadge($request, $room, $request->input('badge_code'))) { + return redirect()->route('meet.join', $room); + } + + return back()->withErrors([ + 'badge_code' => 'Badge code not found or registration is not confirmed.', ]); } @@ -101,6 +131,10 @@ class JoinController extends Controller return redirect()->route('meet.join', $room); } + if ($this->requiresEventsRegistration($room, $request)) { + return redirect()->route('meet.join', $room); + } + $rules = [ 'email' => ['nullable', 'email'], ]; @@ -118,10 +152,11 @@ class JoinController extends Controller } $user = $request->user(); - $displayName = $user?->name ?? ($validated['display_name'] ?? 'Guest'); - $email = $user?->email ?? ($validated['email'] ?? null); + $registration = EventsRegistrationSession::get($request, $room); + $displayName = $user?->name ?? ($validated['display_name'] ?? ($registration['name'] ?? 'Guest')); + $email = $user?->email ?? ($registration['email'] ?? null); - $kind = $room->isConference() ? 'conference' : 'meeting'; + $kind = $room->isConference() ? 'conference' : ($room->isWebinar() ? 'webinar' : 'meeting'); [$allowed, $reason] = $this->security->canJoin($room, $user, $email ?? $displayName.'@guest.local'); if (! $allowed && $reason !== 'passcode_required') { @@ -164,30 +199,9 @@ class JoinController extends Controller $role = $this->spaces->resolveJoinRole($room, $user, $role); } elseif ($room->isConference() && $role !== 'host') { $role = $this->conferences->resolveJoinRole($room, $user, $email, $role); - - if ($role === 'attendee' && $room->isEventsLinked()) { - $joinEmail = $email ?? $user?->email; - if (! $room->hasEventsRegistrationInvite($joinEmail)) { - $registrationUrl = EventsSourceLink::eventRegistrationUrl($room); - - return $registrationUrl - ? redirect()->away($registrationUrl) - : back()->withErrors(['join' => 'Register for this conference in Ladill Events before joining.']); - } - } } elseif ($room->isWebinar() && $role !== 'host') { if ($room->isEventsLinked()) { - $joinEmail = $email ?? $user?->email; - if (! $room->hasEventsRegistrationInvite($joinEmail)) { - $registrationUrl = EventsSourceLink::eventRegistrationUrl($room); - - return $registrationUrl - ? redirect()->away($registrationUrl) - : back()->withErrors(['join' => 'Register for this webinar in Ladill Events before joining.']); - } - $role = 'attendee'; - $displayName = $displayName ?? $user?->name; } else { return back()->withErrors([ 'join' => 'This webinar must be linked to a Ladill Events event before attendees can register and join.', @@ -301,4 +315,79 @@ class JoinController extends Controller return null; } + + protected function isRoomHost(Request $request, Room $room): bool + { + $user = $request->user(); + + return $user && $user->ownerRef() === $room->host_user_ref; + } + + protected function requiresEventsRegistration(Room $room, Request $request): bool + { + if (! $room->isEventsLinked()) { + return false; + } + + if ($this->isRoomHost($request, $room)) { + return false; + } + + if (! $room->isWebinar() && ! $room->isConference()) { + return false; + } + + return ! EventsRegistrationSession::isVerified($request, $room); + } + + protected function verifyAndStoreBadge(Request $request, Room $room, string $badgeCode): bool + { + $eventId = EventsSourceLink::eventId($room); + if (! $eventId || ! $this->events->isConfigured()) { + return false; + } + + $registration = $this->events->verifyRegistration($eventId, $room->owner_ref, $badgeCode); + if (! $registration) { + return false; + } + + EventsRegistrationSession::store($request, $room, [ + 'email' => $registration['attendee_email'] ?? null, + 'name' => $registration['attendee_name'] ?? null, + 'badge_code' => $registration['badge_code'] ?? $badgeCode, + ]); + + return EventsRegistrationSession::isVerified($request, $room); + } + + protected function gateView(Request $request, Room $room): View + { + $meetReturn = EventsSourceLink::meetReturnUrl($room); + $registrationUrl = EventsSourceLink::eventRegistrationUrl($room, $meetReturn); + $sessionNoun = $room->isConference() ? 'conference' : 'webinar'; + + return view('meet.join.gate', [ + 'room' => $room, + 'organization' => $room->organization, + 'eventTitle' => EventsSourceLink::eventTitle($room), + 'registrationUrl' => $registrationUrl, + 'sessionNoun' => $sessionNoun, + ]); + } + + protected function displayNameView(Request $request, Room $room): View + { + $registration = EventsRegistrationSession::get($request, $room); + $sessionNoun = $room->isConference() ? 'conference' : ($room->isWebinar() ? 'webinar' : 'meeting'); + + return view('meet.join.show', [ + 'room' => $room, + 'organization' => $room->organization, + 'user' => $request->user(), + 'isWebinar' => $room->isWebinar(), + 'sessionNoun' => $sessionNoun, + 'defaultDisplayName' => $registration['name'] ?? '', + ]); + } } diff --git a/app/Services/Integrations/EventsClient.php b/app/Services/Integrations/EventsClient.php index c737eff..3ceff2b 100644 --- a/app/Services/Integrations/EventsClient.php +++ b/app/Services/Integrations/EventsClient.php @@ -43,6 +43,31 @@ class EventsClient return (array) ($response['event'] ?? []); } + /** @return array|null */ + public function verifyRegistration(int $eventId, string $ownerRef, string $badgeCode): ?array + { + try { + $response = $this->client()->post("events/{$eventId}/verify-registration", [ + 'owner_ref' => $ownerRef, + 'badge_code' => $badgeCode, + ]); + } catch (ConnectionException) { + throw new \RuntimeException('Could not reach Ladill Events.'); + } + + if ($response->status() === 404) { + return null; + } + + if ($response->failed()) { + throw new \RuntimeException('Events service error: '.($response->json('message') ?? $response->body())); + } + + $registration = $response->json('registration'); + + return is_array($registration) ? $registration : null; + } + /** * @return array */ diff --git a/app/Support/EventsRegistrationSession.php b/app/Support/EventsRegistrationSession.php new file mode 100644 index 0000000..478be1d --- /dev/null +++ b/app/Support/EventsRegistrationSession.php @@ -0,0 +1,44 @@ +uuid}"; + } + + /** @return array{email: ?string, name: ?string, badge_code: ?string}|null */ + public static function get(Request $request, Room $room): ?array + { + $data = $request->session()->get(self::key($room)); + + return is_array($data) ? $data : null; + } + + public static function isVerified(Request $request, Room $room): bool + { + $data = self::get($request, $room); + + return filled($data['badge_code'] ?? null) && filled($data['email'] ?? null); + } + + /** @param array{email?: ?string, name?: ?string, badge_code?: ?string} $data */ + public static function store(Request $request, Room $room, array $data): void + { + $request->session()->put(self::key($room), [ + 'email' => $data['email'] ?? null, + 'name' => $data['name'] ?? null, + 'badge_code' => $data['badge_code'] ?? null, + ]); + } + + public static function forget(Request $request, Room $room): void + { + $request->session()->forget(self::key($room)); + } +} diff --git a/app/Support/EventsSourceLink.php b/app/Support/EventsSourceLink.php index 9292a5b..15287b4 100644 --- a/app/Support/EventsSourceLink.php +++ b/app/Support/EventsSourceLink.php @@ -51,18 +51,32 @@ class EventsSourceLink return $eventId ? self::baseUrl().'/events/'.$eventId : null; } - public static function eventRegistrationUrl(Room $room): ?string + public static function meetReturnUrl(Room $room): string + { + return route('meet.join', $room, absolute: true); + } + + public static function eventRegistrationUrl(Room $room, ?string $meetReturn = null): ?string { $source = (array) ($room->source ?? []); $stored = trim((string) ($source['registration_url'] ?? '')); - if ($stored !== '') { - return $stored; + $url = $stored !== '' + ? $stored + : (($eventId = self::eventId($room)) + ? self::baseUrl().'/q/'.($source['short_code'] ?? '') + : null); + + if ($url === null) { + return null; } - $eventId = self::eventId($room); + $meetReturn = trim((string) ($meetReturn ?? '')); + if ($meetReturn === '') { + return $url; + } - return $eventId ? self::baseUrl().'/q/'.($source['short_code'] ?? '') : null; + return $url.(str_contains($url, '?') ? '&' : '?').'meet_return='.urlencode($meetReturn); } public static function eventQrUrl(Room $room): ?string diff --git a/resources/js/meet-public.js b/resources/js/meet-public.js index 41932e2..73e1eb5 100644 --- a/resources/js/meet-public.js +++ b/resources/js/meet-public.js @@ -71,5 +71,72 @@ Alpine.data('leaveFeedback', () => ({ }, })); +Alpine.data('meetBadgeScanner', () => ({ + scanning: false, + scanError: '', + scanner: null, + + toggle() { + if (this.scanning) { + this.stop(); + return; + } + + this.scanning = true; + this.scanError = ''; + this.$nextTick(() => this.start()); + }, + + start() { + if (typeof Html5Qrcode === 'undefined') { + this.scanError = 'QR scanner could not load. Enter your badge code manually.'; + this.scanning = false; + return; + } + + const el = document.getElementById('meet-badge-qr-reader'); + if (!el) { + return; + } + + this.scanner = new Html5Qrcode('meet-badge-qr-reader'); + this.scanner.start( + { facingMode: 'environment' }, + { fps: 10, qrbox: { width: 220, height: 220 } }, + (decoded) => { + const code = (decoded || '').trim().toUpperCase(); + if (!code) { + return; + } + + this.stop(); + const input = document.querySelector('input[name="badge_code"]'); + if (input) { + input.value = code; + } + + input?.closest('form')?.requestSubmit(); + }, + () => {}, + ).catch(() => { + this.scanError = 'Camera access was denied or unavailable.'; + this.scanning = false; + }); + }, + + stop() { + if (!this.scanner) { + this.scanning = false; + return; + } + + this.scanner.stop().catch(() => {}).finally(() => { + this.scanner.clear().catch(() => {}); + this.scanner = null; + this.scanning = false; + }); + }, +})); + window.Alpine = Alpine; Alpine.start(); diff --git a/resources/views/meet/join/gate.blade.php b/resources/views/meet/join/gate.blade.php new file mode 100644 index 0000000..d61681c --- /dev/null +++ b/resources/views/meet/join/gate.blade.php @@ -0,0 +1,78 @@ + +
+
+

{{ $room->title }}

+ + @if ($eventTitle) +

{{ $eventTitle }}

+ @endif + + @if ($room->scheduled_at) +

+ {{ $room->scheduled_at->timezone($room->timezone)->format('l, j F Y · g:i A T') }} +

+ @endif + +

+ Register for this event before joining the {{ $sessionNoun }}. Paid tickets must be completed before you can enter. +

+ + @if ($errors->any()) +
+ {{ $errors->first() }} +
+ @endif + + @if ($registrationUrl) + + Register for event + + @else +

+ Event registration is not available. Contact the organizer. +

+ @endif + +
+ +
+ Already registered? +
+
+ +
+ @csrf + + + + + +
+ +
+ + +
+
+
+ +

+
+
+
+ + @push('head') + + @endpush +
diff --git a/resources/views/meet/join/show.blade.php b/resources/views/meet/join/show.blade.php index 1177386..eadb9a0 100644 --- a/resources/views/meet/join/show.blade.php +++ b/resources/views/meet/join/show.blade.php @@ -1,7 +1,4 @@ - @php - $sessionNoun = $room->isConference() ? 'conference' : 'meeting'; - @endphp

{{ $room->title }}

@@ -32,15 +29,8 @@ @guest - - @if ($isWebinar ?? false) - - - @endif @endguest @auth @@ -50,11 +40,11 @@ @endauth @if ($room->setting('waiting_room', true) && ! ($user && $user->ownerRef() === $room->host_user_ref)) -

The host will admit you when the {{ $sessionNoun }} is ready.

+

The host will admit you when the {{ $sessionNoun ?? 'session' }} is ready.

@endif diff --git a/routes/web.php b/routes/web.php index 1f252b9..95e5ca9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -47,6 +47,7 @@ Route::get('/r/{room}', [JoinController::class, 'show'])->name('meet.join'); Route::get('/r/{room}/waiting', [JoinController::class, 'waiting'])->name('meet.join.waiting'); Route::get('/r/{room}/waiting/status', [JoinController::class, 'waitingStatus'])->name('meet.join.waiting.status'); Route::post('/r/{room}/passcode', [JoinController::class, 'verifyPasscode'])->name('meet.join.passcode'); +Route::post('/r/{room}/verify-badge', [JoinController::class, 'verifyBadge'])->name('meet.join.verify-badge'); Route::post('/r/{room}/enter', [JoinController::class, 'enter'])->name('meet.join.enter'); Route::get('/room/{session}/left', [LeaveController::class, 'show'])->name('meet.left'); diff --git a/tests/Feature/EventsJoinGateTest.php b/tests/Feature/EventsJoinGateTest.php new file mode 100644 index 0000000..96d6dd1 --- /dev/null +++ b/tests/Feature/EventsJoinGateTest.php @@ -0,0 +1,174 @@ +withoutMiddleware(EnsurePlatformSession::class); + + config([ + 'meet.events_api_url' => 'https://events.test/api/service/v1', + 'meet.events_api_key' => 'test-events-key', + ]); + + $this->user = User::create([ + 'public_id' => 'host-events-001', + 'name' => 'Event Host', + 'email' => 'host@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->user->public_id, + 'name' => 'Events Org', + 'slug' => 'events-org', + 'timezone' => 'UTC', + 'settings' => ['onboarded' => true], + ]); + + Member::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->user->public_id, + 'role' => 'owner', + ]); + + $base = rtrim((string) config('billing.api_url'), '/'); + Http::fake([ + $base.'/can-afford*' => Http::response(['affordable' => true]), + $base.'/debit' => Http::response(['ok' => true]), + $base.'/balance*' => Http::response(['balance_minor' => 100000]), + 'https://events.test/api/service/v1/events/*/verify-registration' => Http::response([ + 'registration' => [ + 'attendee_name' => 'Registered Guest', + 'attendee_email' => 'guest@example.com', + 'badge_code' => 'BADGE123', + ], + ]), + ]); + } + + public function test_events_linked_webinar_shows_registration_gate_for_guest(): void + { + $room = $this->createEventsLinkedRoom('webinar'); + + $this->get('/r/'.$room->uuid) + ->assertOk() + ->assertSee('Register for event', false) + ->assertSee('Already registered?', false) + ->assertDontSee('How should we show your name?', false); + } + + public function test_badge_verification_leads_to_display_name_screen(): void + { + $room = $this->createEventsLinkedRoom('webinar'); + + $this->post('/r/'.$room->uuid.'/verify-badge', ['badge_code' => 'BADGE123']) + ->assertRedirect(route('meet.join', $room)); + + $this->get('/r/'.$room->uuid) + ->assertOk() + ->assertSee('How should we show your name?', false) + ->assertSee('value="Registered Guest"', false) + ->assertDontSee('Register for event', false); + } + + public function test_verified_guest_can_join_live_webinar_with_display_name_only(): void + { + $room = $this->createEventsLinkedRoom('webinar', [ + 'settings' => array_merge(config('meet.default_settings'), [ + 'join_before_host' => true, + 'waiting_room' => false, + ]), + ]); + + $session = Session::create([ + 'owner_ref' => $this->user->public_id, + 'room_id' => $room->id, + 'media_room_name' => 'room-'.$room->uuid, + 'status' => 'live', + 'started_at' => now(), + ]); + $room->update(['status' => 'live']); + + $this->post('/r/'.$room->uuid.'/verify-badge', ['badge_code' => 'BADGE123']) + ->assertRedirect(route('meet.join', $room)); + + $this->post('/r/'.$room->uuid.'/enter', [ + 'display_name' => 'Registered Guest', + ])->assertRedirect(route('meet.room', $session)); + + $this->assertDatabaseHas('meet_participants', [ + 'session_id' => $session->id, + 'display_name' => 'Registered Guest', + 'email' => 'guest@example.com', + 'role' => 'attendee', + ]); + } + + public function test_badge_query_param_auto_verifies_on_join_url(): void + { + $room = $this->createEventsLinkedRoom('webinar'); + + $this->get('/r/'.$room->uuid.'?badge=BADGE123') + ->assertRedirect(route('meet.join', $room)); + + $this->get('/r/'.$room->uuid) + ->assertOk() + ->assertSee('How should we show your name?', false); + } + + public function test_host_skips_registration_gate(): void + { + $room = $this->createEventsLinkedRoom('webinar'); + + $this->actingAs($this->user) + ->get('/r/'.$room->uuid) + ->assertOk() + ->assertSee('Joining as', false) + ->assertDontSee('Register for event', false); + } + + /** @param array $overrides */ + protected function createEventsLinkedRoom(string $type, array $overrides = []): Room + { + return Room::create(array_merge([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'host_user_ref' => $this->user->public_id, + 'title' => 'Linked session', + 'type' => $type, + 'status' => 'scheduled', + 'scheduled_at' => now()->addDay(), + 'timezone' => 'UTC', + 'settings' => config('meet.default_settings'), + 'source' => [ + 'app' => 'events', + 'entity_type' => 'virtual_session', + 'entity_id' => 'vs-test', + 'event_id' => '42', + 'event_title' => 'Summit 2026', + 'short_code' => 'summit26', + 'registration_url' => 'https://ladl.link/summit26', + ], + ], $overrides)); + } +}