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 {