Gate Events-linked joins behind registration and badge check-in.
Deploy Ladill Meet / deploy (push) Successful in 43s

Attendees register or sign in with a badge before the display-name screen; email is no longer collected on the Meet join form.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-03 14:36:40 +00:00
co-authored by Cursor
parent 4fceda8c1f
commit 56d486d15c
9 changed files with 529 additions and 47 deletions
+118 -29
View File
@@ -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'] ?? '',
]);
}
}
@@ -43,6 +43,31 @@ class EventsClient
return (array) ($response['event'] ?? []);
}
/** @return array<string, mixed>|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<string, mixed>
*/
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Support;
use App\Models\Room;
use Illuminate\Http\Request;
class EventsRegistrationSession
{
private static function key(Room $room): string
{
return "meet.events.registration.{$room->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));
}
}
+19 -5
View File
@@ -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
+67
View File
@@ -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();
+78
View File
@@ -0,0 +1,78 @@
<x-meet-kiosk-layout :title="'Register · '.$room->title" :organization="$organization">
<div class="flex min-h-[calc(100vh-8rem)] flex-col items-center justify-center text-center">
<div class="w-full max-w-lg">
<h1 class="text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl">{{ $room->title }}</h1>
@if ($eventTitle)
<p class="mt-2 text-sm font-medium text-indigo-600">{{ $eventTitle }}</p>
@endif
@if ($room->scheduled_at)
<p class="mt-3 text-base text-slate-500">
{{ $room->scheduled_at->timezone($room->timezone)->format('l, j F Y · g:i A T') }}
</p>
@endif
<p class="mt-6 text-sm leading-relaxed text-slate-600">
Register for this event before joining the {{ $sessionNoun }}. Paid tickets must be completed before you can enter.
</p>
@if ($errors->any())
<div class="mt-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-left text-sm text-red-700">
{{ $errors->first() }}
</div>
@endif
@if ($registrationUrl)
<a href="{{ $registrationUrl }}"
class="btn-primary btn-primary-lg mt-8 inline-flex w-full items-center justify-center py-4 text-base font-semibold">
Register for event
</a>
@else
<p class="mt-8 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Event registration is not available. Contact the organizer.
</p>
@endif
<div class="relative my-10">
<div class="absolute inset-0 flex items-center" aria-hidden="true">
<div class="w-full border-t border-slate-200"></div>
</div>
<div class="relative flex justify-center">
<span class="bg-slate-100 px-3 text-sm font-medium text-slate-500">Already registered?</span>
</div>
</div>
<form method="POST" action="{{ route('meet.join.verify-badge', $room) }}" class="text-left">
@csrf
<label class="block text-sm font-medium text-slate-700">Badge code</label>
<input type="text" name="badge_code" required placeholder="Enter your badge code"
value="{{ old('badge_code') }}"
autocomplete="off"
class="mt-2 w-full rounded-2xl border-slate-200 bg-white px-4 py-3.5 font-mono uppercase tracking-widest text-slate-900 shadow-sm ring-1 ring-slate-200 placeholder:normal-case placeholder:tracking-normal placeholder:text-slate-400 focus:border-indigo-500 focus:ring-indigo-500">
<button type="submit" class="btn-primary mt-4 w-full py-3.5 text-sm font-semibold">
Sign in with badge
</button>
</form>
<div class="mt-6" x-data="meetBadgeScanner" x-cloak>
<button type="button" @click="toggle()"
class="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">
<span x-text="scanning ? 'Stop scanning' : 'Scan badge QR code'"></span>
</button>
<div x-show="scanning" class="mt-4 overflow-hidden rounded-2xl border border-slate-200 bg-black">
<div id="meet-badge-qr-reader" class="w-full"></div>
</div>
<p x-show="scanError" x-text="scanError" class="mt-3 text-left text-sm text-red-600"></p>
</div>
</div>
</div>
@push('head')
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
@endpush
</x-meet-kiosk-layout>
+3 -13
View File
@@ -1,7 +1,4 @@
<x-meet-kiosk-layout :title="'Join · '.$room->title" :organization="$organization">
@php
$sessionNoun = $room->isConference() ? 'conference' : 'meeting';
@endphp
<div class="flex min-h-[calc(100vh-8rem)] flex-col items-center justify-center text-center">
<div class="w-full max-w-lg">
<h1 class="text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl">{{ $room->title }}</h1>
@@ -32,15 +29,8 @@
@guest
<label class="block text-sm font-medium text-slate-700">Your name</label>
<input type="text" name="display_name" required placeholder="How should we show your name?"
value="{{ old('display_name') }}"
value="{{ old('display_name', $defaultDisplayName ?? '') }}"
class="mt-2 w-full rounded-2xl border-slate-200 bg-white px-4 py-3.5 text-slate-900 shadow-sm ring-1 ring-slate-200 placeholder:text-slate-400 focus:border-indigo-500 focus:ring-indigo-500">
@if ($isWebinar ?? false)
<label class="mt-4 block text-sm font-medium text-slate-700">Email</label>
<input type="email" name="email" required placeholder="you@example.com"
value="{{ old('email') }}"
class="mt-2 w-full rounded-2xl border-slate-200 bg-white px-4 py-3.5 text-slate-900 shadow-sm ring-1 ring-slate-200 placeholder:text-slate-400 focus:border-indigo-500 focus:ring-indigo-500">
@endif
@endguest
@auth
@@ -50,11 +40,11 @@
@endauth
@if ($room->setting('waiting_room', true) && ! ($user && $user->ownerRef() === $room->host_user_ref))
<p class="mt-4 text-xs text-slate-500">The host will admit you when the {{ $sessionNoun }} is ready.</p>
<p class="mt-4 text-xs text-slate-500">The host will admit you when the {{ $sessionNoun ?? 'session' }} is ready.</p>
@endif
<button type="submit" class="btn-primary btn-primary-lg mt-8 w-full py-4 text-base font-semibold">
Join {{ $sessionNoun }}
Join {{ $sessionNoun ?? 'session' }}
</button>
</form>
+1
View File
@@ -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');
+174
View File
@@ -0,0 +1,174 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Room;
use App\Models\Session;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class EventsJoinGateTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Organization $organization;
protected function setUp(): void
{
parent::setUp();
$this->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<string, mixed> $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));
}
}