From f5f599d1e397dab36ddbd8d284bba93c280d5d84 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Fri, 3 Jul 2026 15:48:35 +0000 Subject: [PATCH] Let invited speakers bypass Events registration and join as panelists. Verify speaker tokens from Events on join, grant panelist role for linked webinars, and point bulk comms links to the attendees page instead of the JSON preview API. Co-authored-by: Cursor --- app/Http/Controllers/Meet/JoinController.php | 38 ++++++++++++++++- app/Services/Integrations/EventsClient.php | 25 +++++++++++ app/Support/EventsRegistrationSession.php | 17 +++++++- app/Support/EventsSourceLink.php | 2 +- tests/Feature/EventsJoinGateTest.php | 45 ++++++++++++++++++++ 5 files changed, 122 insertions(+), 5 deletions(-) diff --git a/app/Http/Controllers/Meet/JoinController.php b/app/Http/Controllers/Meet/JoinController.php index ac475f5..9a290e0 100644 --- a/app/Http/Controllers/Meet/JoinController.php +++ b/app/Http/Controllers/Meet/JoinController.php @@ -88,6 +88,17 @@ class JoinController extends Controller ]); } + $speakerToken = trim((string) $request->query('speaker', '')); + if ($speakerToken !== '' && $this->requiresEventsRegistration($room, $request)) { + if ($this->verifyAndStoreSpeaker($request, $room, $speakerToken)) { + return redirect()->route('meet.join', $room); + } + + return $this->gateView($request, $room)->withErrors([ + 'speaker' => 'Speaker invitation link is invalid or expired.', + ]); + } + if ($this->requiresEventsRegistration($room, $request)) { return $this->gateView($request, $room); } @@ -200,7 +211,9 @@ class JoinController extends Controller } elseif ($room->isConference() && $role !== 'host') { $role = $this->conferences->resolveJoinRole($room, $user, $email, $role); } elseif ($room->isWebinar() && $role !== 'host') { - if ($room->isEventsLinked()) { + if (EventsRegistrationSession::isSpeakerVerified($request, $room)) { + $role = 'panelist'; + } elseif ($room->isEventsLinked()) { $role = 'attendee'; } else { return back()->withErrors([ @@ -337,7 +350,28 @@ class JoinController extends Controller return false; } - return ! EventsRegistrationSession::isVerified($request, $room); + return ! EventsRegistrationSession::canJoinEventsLinked($request, $room); + } + + protected function verifyAndStoreSpeaker(Request $request, Room $room, string $speakerToken): bool + { + $eventId = EventsSourceLink::eventId($room); + if (! $eventId || ! $this->events->isConfigured()) { + return false; + } + + $speaker = $this->events->verifySpeakerInvite($eventId, $room->owner_ref, $speakerToken); + if (! $speaker) { + return false; + } + + EventsRegistrationSession::store($request, $room, [ + 'email' => $speaker['email'] ?? null, + 'name' => $speaker['name'] ?? null, + 'speaker_token' => $speakerToken, + ]); + + return EventsRegistrationSession::isSpeakerVerified($request, $room); } protected function verifyAndStoreBadge(Request $request, Room $room, string $badgeCode): bool diff --git a/app/Services/Integrations/EventsClient.php b/app/Services/Integrations/EventsClient.php index 3ceff2b..c086d57 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 verifySpeakerInvite(int $eventId, string $ownerRef, string $speakerToken): ?array + { + try { + $response = $this->client()->post("events/{$eventId}/verify-speaker", [ + 'owner_ref' => $ownerRef, + 'speaker_token' => $speakerToken, + ]); + } 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())); + } + + $speaker = $response->json('speaker'); + + return is_array($speaker) ? $speaker : null; + } + /** @return array|null */ public function verifyRegistration(int $eventId, string $ownerRef, string $badgeCode): ?array { diff --git a/app/Support/EventsRegistrationSession.php b/app/Support/EventsRegistrationSession.php index 478be1d..46e2f54 100644 --- a/app/Support/EventsRegistrationSession.php +++ b/app/Support/EventsRegistrationSession.php @@ -12,7 +12,7 @@ class EventsRegistrationSession return "meet.events.registration.{$room->uuid}"; } - /** @return array{email: ?string, name: ?string, badge_code: ?string}|null */ + /** @return array{email: ?string, name: ?string, badge_code: ?string, speaker_token: ?string}|null */ public static function get(Request $request, Room $room): ?array { $data = $request->session()->get(self::key($room)); @@ -27,13 +27,26 @@ class EventsRegistrationSession return filled($data['badge_code'] ?? null) && filled($data['email'] ?? null); } - /** @param array{email?: ?string, name?: ?string, badge_code?: ?string} $data */ + public static function isSpeakerVerified(Request $request, Room $room): bool + { + $data = self::get($request, $room); + + return filled($data['speaker_token'] ?? null) && filled($data['email'] ?? null); + } + + public static function canJoinEventsLinked(Request $request, Room $room): bool + { + return self::isVerified($request, $room) || self::isSpeakerVerified($request, $room); + } + + /** @param array{email?: ?string, name?: ?string, badge_code?: ?string, speaker_token?: ?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, + 'speaker_token' => $data['speaker_token'] ?? null, ]); } diff --git a/app/Support/EventsSourceLink.php b/app/Support/EventsSourceLink.php index 15287b4..432a18e 100644 --- a/app/Support/EventsSourceLink.php +++ b/app/Support/EventsSourceLink.php @@ -90,7 +90,7 @@ class EventsSourceLink { $eventId = self::eventId($room); - return $eventId ? self::baseUrl().'/events/'.$eventId.'/attendees/comms-preview' : null; + return $eventId ? self::baseUrl().'/events/'.$eventId.'/attendees#comms' : null; } public static function eventBadgesUrl(Room $room): ?string diff --git a/tests/Feature/EventsJoinGateTest.php b/tests/Feature/EventsJoinGateTest.php index 96d6dd1..756fd23 100644 --- a/tests/Feature/EventsJoinGateTest.php +++ b/tests/Feature/EventsJoinGateTest.php @@ -63,6 +63,13 @@ class EventsJoinGateTest extends TestCase 'badge_code' => 'BADGE123', ], ]), + 'https://events.test/api/service/v1/events/*/verify-speaker' => Http::response([ + 'speaker' => [ + 'name' => 'Keynote Speaker', + 'email' => 'speaker@example.com', + 'role' => 'Keynote', + ], + ]), ]); } @@ -147,6 +154,44 @@ class EventsJoinGateTest extends TestCase ->assertDontSee('Register for event', false); } + public function test_speaker_token_skips_registration_gate_and_grants_panelist_role(): 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->get('/r/'.$room->uuid.'?speaker=speaker-token-123') + ->assertRedirect(route('meet.join', $room)); + + $this->get('/r/'.$room->uuid) + ->assertOk() + ->assertSee('How should we show your name?', false) + ->assertDontSee('Register for event', false); + + $this->post('/r/'.$room->uuid.'/enter', [ + 'display_name' => 'Keynote Speaker', + ])->assertRedirect(route('meet.room', $session)); + + $this->assertDatabaseHas('meet_participants', [ + 'session_id' => $session->id, + 'display_name' => 'Keynote Speaker', + 'email' => 'speaker@example.com', + 'role' => 'panelist', + ]); + } + /** @param array $overrides */ protected function createEventsLinkedRoom(string $type, array $overrides = []): Room {