Route webinar and conference registration through Ladill Events.
Deploy Ladill Meet / deploy (push) Successful in 1m26s

Add Events linking UI and service client, remove Meet-native registration and invitation flows for these room types, and redirect attendees to Events registration when joining.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-02 07:38:50 +00:00
co-authored by Cursor
parent 8162827957
commit 34d0a9c504
20 changed files with 595 additions and 286 deletions
@@ -52,15 +52,8 @@ class ConferenceController extends Controller
$this->authorizeAbility($request, 'meetings.create');
$organization = $this->organization($request);
$members = Member::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('user_ref', '!=', $this->ownerRef($request))
->orderBy('user_ref')
->get();
return view('meet.conferences.create', [
'organization' => $organization,
'members' => $members,
'timezones' => timezone_identifiers_list(),
'defaultSettings' => config('meet.default_settings'),
]);
@@ -78,9 +71,6 @@ class ConferenceController extends Controller
'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'],
'timezone' => ['nullable', 'timezone'],
'passcode' => ['nullable', 'string', 'max:20'],
'invite_emails' => ['nullable', 'string'],
'presenter_refs' => ['nullable', 'array'],
'presenter_refs.*' => ['string', 'max:64'],
'waiting_room' => ['sometimes', 'boolean'],
'join_before_host' => ['sometimes', 'boolean'],
'mute_on_join' => ['sometimes', 'boolean'],
@@ -91,12 +81,6 @@ class ConferenceController extends Controller
'panel_discussions' => ['sometimes', 'boolean'],
]);
$presenterRefs = collect($validated['presenter_refs'] ?? [])
->filter()
->unique()
->values()
->all();
$settings = [
'waiting_room' => $request->boolean('waiting_room', true),
'join_before_host' => $request->boolean('join_before_host'),
@@ -109,7 +93,7 @@ class ConferenceController extends Controller
'stage_layout' => 'plenary',
'panels' => [],
'stage_mode' => true,
'presenter_refs' => $presenterRefs,
'presenter_refs' => [],
];
$data = array_merge($validated, [
@@ -121,14 +105,6 @@ class ConferenceController extends Controller
$room = $this->rooms->create($request->user(), $organization, $data);
if ($emails = trim((string) ($validated['invite_emails'] ?? ''))) {
$invites = collect(preg_split('/[\s,;]+/', $emails))
->filter()
->map(fn ($email) => ['email' => $email, 'role' => 'panelist'])
->all();
$this->invitations->inviteToRoom($room, $invites, $request->user());
}
if ($room->scheduled_at) {
$this->calendar->syncRoomCreated($room, $request->user());
}
@@ -144,43 +120,7 @@ class ConferenceController extends Controller
$room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles']);
$presenterRefs = $this->conferences->presenterRefs($room);
$speakers = Member::owned($this->ownerRef($request))
->where('organization_id', $room->organization_id)
->whereIn('user_ref', $presenterRefs)
->get();
$speakerUsers = User::query()
->whereIn('public_id', $presenterRefs)
->get()
->keyBy('public_id');
$members = Member::owned($this->ownerRef($request))
->where('organization_id', $room->organization_id)
->where('user_ref', '!=', $this->ownerRef($request))
->orderBy('user_ref')
->get();
$memberUsers = User::query()
->whereIn('public_id', $members->pluck('user_ref'))
->get()
->keyBy('public_id');
$canManageSpeakers = app(\App\Services\Meet\MeetPermissions::class)
->can($this->member($request), 'meetings.manage');
$eventsEventUrl = \App\Support\EventsSourceLink::eventAdminUrl($room);
return view('meet.conferences.show', compact(
'room',
'speakers',
'speakerUsers',
'members',
'memberUsers',
'presenterRefs',
'canManageSpeakers',
'eventsEventUrl',
));
return view('meet.conferences.show', compact('room'));
}
public function updateSpeakers(Request $request, Room $room): RedirectResponse
@@ -189,6 +129,17 @@ class ConferenceController extends Controller
$this->authorizeOwner($request, $room);
abort_unless($room->isConference(), 404);
if ($room->isEventsLinked()) {
if ($request->has('panel_discussions')) {
$settings = array_merge($room->settings ?? [], [
'panel_discussions' => $request->boolean('panel_discussions'),
]);
$room->update(['settings' => $settings]);
}
return redirect()->route('meet.conferences.show', $room)->with('success', 'Conference settings updated.');
}
$validated = $request->validate([
'presenter_refs' => ['nullable', 'array'],
'presenter_refs.*' => ['string', 'max:64'],
@@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
use App\Models\Room;
use App\Services\Integrations\EventsClient;
use App\Services\Integrations\EventsLinkService;
use App\Support\EventsSourceLink;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EventsLinkController extends Controller
{
use ScopesToAccount;
public function __construct(
protected EventsLinkService $links,
protected EventsClient $eventsClient,
) {}
public function show(Request $request, Room $room): View|RedirectResponse
{
$this->authorizeAbility($request, 'meetings.manage');
$this->authorizeOwner($request, $room);
abort_unless($room->isWebinar() || $room->isConference(), 404);
if (! $this->eventsClient->isConfigured()) {
return redirect()
->route($room->isWebinar() ? 'meet.webinars.show' : 'meet.conferences.show', $room)
->withErrors(['events' => 'Ladill Events integration is not configured on this Meet instance.']);
}
$events = $this->links->linkableEvents($request->user());
$expectedType = $room->isConference() ? 'town_hall' : 'webinar';
return view('meet.events.link', [
'room' => $room,
'events' => $events,
'expectedType' => $expectedType,
'eventsAppUrl' => EventsSourceLink::baseUrl(),
'isLinked' => EventsSourceLink::isLinked($room),
]);
}
public function store(Request $request, Room $room): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.manage');
$this->authorizeOwner($request, $room);
abort_unless($room->isWebinar() || $room->isConference(), 404);
$validated = $request->validate([
'event_id' => ['required', 'integer', 'min:1'],
'virtual_session_id' => ['nullable', 'string', 'max:64'],
]);
try {
$this->links->link(
$room,
$request->user(),
(int) $validated['event_id'],
$validated['virtual_session_id'] ?? null,
);
} catch (\RuntimeException $e) {
return back()->withErrors(['events' => $e->getMessage()])->withInput();
}
$route = $room->isWebinar() ? 'meet.webinars.show' : 'meet.conferences.show';
return redirect()->route($route, $room)->with('success', 'Linked to Ladill Events. Registration and invitations are now managed in Events.');
}
public function destroy(Request $request, Room $room): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.manage');
$this->authorizeOwner($request, $room);
$this->links->unlink($room);
$route = $room->isWebinar() ? 'meet.webinars.show' : 'meet.conferences.show';
return redirect()->route($route, $room)->with('success', 'Events link removed.');
}
}
@@ -9,6 +9,7 @@ use App\Models\Invitation;
use App\Models\Room;
use App\Services\Meet\ContactService;
use App\Services\Meet\InvitationService;
use App\Support\EventsSourceLink;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -24,11 +25,25 @@ class InvitationController extends Controller
protected ContactService $contacts,
) {}
public function index(Request $request, Room $room): View
public function index(Request $request, Room $room): View|RedirectResponse
{
$this->authorizeAbility($request, 'meetings.manage');
$this->authorizeOwner($request, $room);
if ($room->isWebinar() || $room->isConference()) {
$adminUrl = EventsSourceLink::eventCommsUrl($room) ?? EventsSourceLink::eventAdminUrl($room);
if ($adminUrl) {
return redirect()->away($adminUrl);
}
$showRoute = $room->isWebinar() ? 'meet.webinars.show' : 'meet.conferences.show';
return redirect()
->route($showRoute, $room)
->withErrors(['invitations' => 'Link this session to Ladill Events to send bulk invitations.']);
}
$room->load(['invitations' => fn ($q) => $q->orderByDesc('created_at')]);
$groups = ContactGroup::owned($this->ownerRef($request))
->where('organization_id', $room->organization_id)
+27 -12
View File
@@ -11,7 +11,7 @@ use App\Services\Meet\InvitationService;
use App\Services\Meet\SecurityPolicyService;
use App\Services\Meet\SessionService;
use App\Services\Meet\SpaceService;
use App\Services\Meet\WebinarService;
use App\Support\EventsSourceLink;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -23,7 +23,6 @@ class JoinController extends Controller
public function __construct(
protected SessionService $sessions,
protected SecurityPolicyService $security,
protected WebinarService $webinars,
protected SpaceService $spaces,
protected ConferenceService $conferences,
) {}
@@ -165,19 +164,35 @@ class JoinController extends Controller
$role = $this->spaces->resolveJoinRole($room, $user, $role);
} elseif ($room->isConference() && $role !== 'host') {
$role = $this->conferences->resolveJoinRole($room, $user, $email, $role);
} elseif ($room->isWebinar() && $role !== 'host') {
$token = $request->session()->get("meet.webinar.{$room->uuid}");
$reg = $token
? $this->webinars->findByAccessToken($token)
: ($email ? $this->webinars->findApprovedRegistration($room, $email) : null);
if (! $reg) {
return redirect()->route('meet.webinar.register', $room)
->withErrors(['register' => 'Approved registration is required for this webinar.']);
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);
$role = 'attendee';
$displayName = $reg->display_name;
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.',
]);
}
}
if ($room->isConference() && ! $this->conferences->canJoinSession($room, $session, $role)) {
@@ -65,13 +65,11 @@ class WebinarController extends Controller
'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'],
'timezone' => ['nullable', 'timezone'],
'passcode' => ['nullable', 'string', 'max:20'],
'invite_emails' => ['nullable', 'string'],
'waiting_room' => ['sometimes', 'boolean'],
'join_before_host' => ['sometimes', 'boolean'],
'mute_on_join' => ['sometimes', 'boolean'],
'auto_record' => ['sometimes', 'boolean'],
'live_captions' => ['sometimes', 'boolean'],
'webinar_auto_approve' => ['sometimes', 'boolean'],
'panel_discussions' => ['sometimes', 'boolean'],
]);
@@ -81,7 +79,6 @@ class WebinarController extends Controller
'mute_on_join' => $request->boolean('mute_on_join', true),
'auto_record' => $request->boolean('auto_record'),
'live_captions' => $request->boolean('live_captions'),
'webinar_auto_approve' => $request->boolean('webinar_auto_approve'),
'panel_discussions' => $request->boolean('panel_discussions'),
'stage_layout' => 'plenary',
'panels' => [],
@@ -97,14 +94,6 @@ class WebinarController extends Controller
$room = $this->rooms->create($request->user(), $organization, $data);
if ($emails = trim((string) ($validated['invite_emails'] ?? ''))) {
$invites = collect(preg_split('/[\s,;]+/', $emails))
->filter()
->map(fn ($email) => ['email' => $email])
->all();
$this->invitations->inviteToRoom($room, $invites, $request->user());
}
if ($room->scheduled_at) {
$this->calendar->syncRoomCreated($room, $request->user());
}
@@ -118,11 +107,9 @@ class WebinarController extends Controller
$this->authorizeOwner($request, $room);
abort_unless($room->isWebinar(), 404);
$room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles', 'webinarRegistrations']);
$room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles']);
$eventsEventUrl = \App\Support\EventsSourceLink::eventAdminUrl($room);
return view('meet.webinars.show', compact('room', 'eventsEventUrl'));
return view('meet.webinars.show', compact('room'));
}
public function start(Request $request, Room $room): RedirectResponse
@@ -3,23 +3,12 @@
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Models\BreakoutRoom;
use App\Models\MeetPoll;
use App\Models\QaQuestion;
use App\Models\Room;
use App\Models\Session;
use App\Models\SessionFile;
use App\Models\Whiteboard;
use App\Services\Meet\BreakoutService;
use App\Services\Meet\FileSharingService;
use App\Services\Meet\PollService;
use App\Services\Meet\QaService;
use App\Services\Meet\WebinarService;
use App\Services\Meet\WhiteboardService;
use App\Support\EventsSourceLink;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class WebinarRegistrationController extends Controller
{
@@ -27,62 +16,69 @@ class WebinarRegistrationController extends Controller
protected WebinarService $webinars,
) {}
public function show(Room $room): View
public function show(Room $room): View|RedirectResponse
{
abort_unless($room->isWebinar(), 404);
return view('meet.webinar.register', compact('room'));
return $this->redirectToEvents($room);
}
public function store(Request $request, Room $room): View
public function store(Request $request, Room $room): View|RedirectResponse
{
abort_unless($room->isWebinar(), 404);
$validated = $request->validate([
'email' => ['required', 'email'],
'display_name' => ['required', 'string', 'max:100'],
]);
$registration = $this->webinars->register(
$room,
$validated['email'],
$validated['display_name'],
);
return view('meet.webinar.register-done', compact('room', 'registration'));
return $this->redirectToEvents($room);
}
public function manage(Request $request, Room $room): View
public function manage(Request $request, Room $room): View|RedirectResponse
{
$this->authorizeWebinarHost($request, $room);
$registrations = $room->webinarRegistrations()->orderByDesc('created_at')->get();
return view('meet.webinar.registrations', compact('room', 'registrations'));
return $this->redirectToEventsAdmin($room);
}
public function approve(Request $request, Room $room, \App\Models\WebinarRegistration $registration): RedirectResponse
{
$this->authorizeWebinarHost($request, $room);
abort_unless($registration->room_id === $room->id, 404);
$this->webinars->approve($registration, $request->user());
return back()->with('success', 'Registration approved.');
return $this->redirectToEventsAdmin($room);
}
public function reject(Request $request, Room $room, \App\Models\WebinarRegistration $registration): RedirectResponse
{
$this->authorizeWebinarHost($request, $room);
abort_unless($registration->room_id === $room->id, 404);
$this->webinars->reject($registration);
return back()->with('success', 'Registration rejected.');
return $this->redirectToEventsAdmin($room);
}
protected function authorizeWebinarHost(Request $request, Room $room): void
{
abort_unless($request->user()?->ownerRef() === $room->host_user_ref, 403);
}
protected function redirectToEvents(Room $room): RedirectResponse
{
$url = EventsSourceLink::eventRegistrationUrl($room);
if ($url) {
return redirect()->away($url);
}
return redirect()
->route('meet.webinars.show', $room)
->withErrors(['register' => 'Link this webinar to Ladill Events to enable registration.']);
}
protected function redirectToEventsAdmin(Room $room): RedirectResponse
{
$url = EventsSourceLink::eventAdminUrl($room);
if ($url) {
return redirect()->away($url);
}
return redirect()
->route('meet.webinars.show', $room)
->withErrors(['register' => 'Link this webinar to Ladill Events to manage registrations.']);
}
}
+17
View File
@@ -194,4 +194,21 @@ class Room extends Model
{
return rtrim((string) config('app.meet_url', 'https://meet.ladill.com'), '/').'/r/'.$this->uuid;
}
public function isEventsLinked(): bool
{
return \App\Support\EventsSourceLink::isLinked($this);
}
public function hasEventsRegistrationInvite(?string $email): bool
{
if ($email === null || trim($email) === '') {
return false;
}
return $this->invitations()
->where('email', $email)
->whereIn('status', ['pending', 'accepted'])
->exists();
}
}
@@ -0,0 +1,81 @@
<?php
namespace App\Services\Integrations;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
class EventsClient
{
private function client()
{
return Http::baseUrl(rtrim((string) config('meet.events_api_url'), '/'))
->withToken((string) config('meet.events_api_key'))
->acceptJson()
->asJson()
->connectTimeout(10)
->timeout(20);
}
public function isConfigured(): bool
{
return filled(config('meet.events_api_url')) && filled(config('meet.events_api_key'));
}
/**
* @return list<array<string, mixed>>
*/
public function linkableEvents(string $ownerRef): array
{
$response = $this->get('events', ['owner_ref' => $ownerRef]);
return (array) ($response['events'] ?? []);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function linkRoom(int $eventId, array $payload): array
{
$response = $this->post("events/{$eventId}/link-room", $payload);
return (array) ($response['event'] ?? []);
}
/**
* @return array<string, mixed>
*/
private function get(string $path, array $query = []): array
{
try {
$response = $this->client()->get($path, $query);
} catch (ConnectionException) {
throw new \RuntimeException('Could not reach Ladill Events.');
}
if ($response->failed()) {
throw new \RuntimeException('Events service error: '.($response->json('message') ?? $response->body()));
}
return (array) $response->json();
}
/**
* @return array<string, mixed>
*/
private function post(string $path, array $data): array
{
try {
$response = $this->client()->post($path, $data);
} catch (ConnectionException) {
throw new \RuntimeException('Could not reach Ladill Events.');
}
if ($response->failed()) {
throw new \RuntimeException('Events service error: '.($response->json('message') ?? $response->body()));
}
return (array) $response->json();
}
}
@@ -0,0 +1,63 @@
<?php
namespace App\Services\Integrations;
use App\Models\Room;
use App\Models\User;
use App\Support\EventsSourceLink;
class EventsLinkService
{
public function __construct(private readonly EventsClient $events) {}
/**
* @return list<array<string, mixed>>
*/
public function linkableEvents(User $user): array
{
if (! $this->events->isConfigured()) {
return [];
}
return $this->events->linkableEvents($user->ownerRef());
}
public function link(Room $room, User $user, int $eventId, ?string $virtualSessionId = null): Room
{
abort_unless($room->isWebinar() || $room->isConference(), 422);
$result = $this->events->linkRoom($eventId, [
'owner_ref' => $user->ownerRef(),
'meet_room_uuid' => $room->uuid,
'meet_room_type' => $room->isConference() ? 'town_hall' : 'webinar',
'join_url' => $room->joinUrl(),
'title' => $room->title,
'scheduled_at' => $room->scheduled_at?->toIso8601String(),
'duration_minutes' => $room->duration_minutes ?? 60,
'virtual_session_id' => $virtualSessionId,
]);
$room->update([
'source' => [
'app' => 'events',
'entity_type' => 'virtual_session',
'entity_id' => (string) ($result['entity_id'] ?? ''),
'event_id' => (string) $eventId,
'event_title' => (string) ($result['title'] ?? ''),
'registration_url' => (string) ($result['registration_url'] ?? ''),
'short_code' => (string) ($result['short_code'] ?? ''),
],
]);
return $room->fresh();
}
public function unlink(Room $room): Room
{
abort_unless(EventsSourceLink::isLinked($room), 422);
$room->update(['source' => null]);
return $room->fresh();
}
}
+82 -6
View File
@@ -6,21 +6,97 @@ use App\Models\Room;
class EventsSourceLink
{
public static function eventAdminUrl(Room $room): ?string
public static function isLinked(Room $room): bool
{
$source = (array) ($room->source ?? []);
if (($source['app'] ?? '') !== 'events') {
return ($source['app'] ?? '') === 'events' && (int) ($source['event_id'] ?? 0) > 0;
}
public static function eventId(Room $room): ?int
{
if (! self::isLinked($room)) {
return null;
}
$eventId = (int) ($source['event_id'] ?? 0);
if ($eventId <= 0) {
return (int) ((array) ($room->source ?? []))['event_id'];
}
public static function baseUrl(): string
{
return rtrim((string) config('meet.events_app_url', 'https://events.ladill.com'), '/');
}
public static function eventAdminUrl(Room $room): ?string
{
$eventId = self::eventId($room);
return $eventId ? self::baseUrl().'/events/'.$eventId.'/attendees' : null;
}
public static function eventEditUrl(Room $room): ?string
{
$eventId = self::eventId($room);
return $eventId ? self::baseUrl().'/events/'.$eventId : null;
}
public static function eventRegistrationUrl(Room $room): ?string
{
$source = (array) ($room->source ?? []);
$stored = trim((string) ($source['registration_url'] ?? ''));
if ($stored !== '') {
return $stored;
}
$eventId = self::eventId($room);
return $eventId ? self::baseUrl().'/q/'.($source['short_code'] ?? '') : null;
}
public static function eventQrUrl(Room $room): ?string
{
$eventId = self::eventId($room);
return $eventId ? self::baseUrl().'/events/'.$eventId.'/download/png' : null;
}
public static function eventCommsUrl(Room $room): ?string
{
$eventId = self::eventId($room);
return $eventId ? self::baseUrl().'/events/'.$eventId.'/attendees/comms-preview' : null;
}
public static function eventBadgesUrl(Room $room): ?string
{
$eventId = self::eventId($room);
return $eventId ? self::baseUrl().'/events/'.$eventId.'/badges' : null;
}
/** @return array<string, string|null> */
public static function urls(Room $room): array
{
return [
'admin' => self::eventAdminUrl($room),
'edit' => self::eventEditUrl($room),
'registration' => self::eventRegistrationUrl($room),
'qr' => self::eventQrUrl($room),
'comms' => self::eventCommsUrl($room),
'badges' => self::eventBadgesUrl($room),
];
}
public static function eventTitle(Room $room): ?string
{
if (! self::isLinked($room)) {
return null;
}
$base = rtrim((string) config('meet.events_app_url', 'https://events.ladill.com'), '/');
$title = trim((string) (((array) ($room->source ?? []))['event_title'] ?? ''));
return $base.'/events/'.$eventId.'/attendees';
return $title !== '' ? $title : null;
}
}