From 34d0a9c50458001ec5eda0ccb915577ea0fa212a Mon Sep 17 00:00:00 2001 From: isaacclad Date: Thu, 2 Jul 2026 07:38:50 +0000 Subject: [PATCH] Route webinar and conference registration through Ladill Events. 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 --- .env.example | 4 + .../Controllers/Meet/ConferenceController.php | 75 +++------------- .../Controllers/Meet/EventsLinkController.php | 86 ++++++++++++++++++ .../Controllers/Meet/InvitationController.php | 17 +++- app/Http/Controllers/Meet/JoinController.php | 39 +++++--- .../Controllers/Meet/WebinarController.php | 17 +--- .../Meet/WebinarRegistrationController.php | 74 ++++++++-------- app/Models/Room.php | 17 ++++ app/Services/Integrations/EventsClient.php | 81 +++++++++++++++++ .../Integrations/EventsLinkService.php | 63 +++++++++++++ app/Support/EventsSourceLink.php | 88 +++++++++++++++++-- config/meet.php | 2 + .../views/meet/conferences/create.blade.php | 22 +---- .../views/meet/conferences/show.blade.php | 87 +++--------------- resources/views/meet/events/link.blade.php | 61 +++++++++++++ .../partials/events-integration.blade.php | 79 +++++++++++++++++ .../views/meet/webinars/create.blade.php | 10 +-- resources/views/meet/webinars/show.blade.php | 40 +-------- routes/web.php | 4 + tests/Feature/MeetWebTest.php | 15 +--- 20 files changed, 595 insertions(+), 286 deletions(-) create mode 100644 app/Http/Controllers/Meet/EventsLinkController.php create mode 100644 app/Services/Integrations/EventsClient.php create mode 100644 app/Services/Integrations/EventsLinkService.php create mode 100644 resources/views/meet/events/link.blade.php create mode 100644 resources/views/meet/partials/events-integration.blade.php diff --git a/.env.example b/.env.example index f66cac0..aade2cc 100644 --- a/.env.example +++ b/.env.example @@ -62,6 +62,10 @@ MEET_API_KEY_HR= MEET_API_KEY_SUPPORT= MEET_API_KEY_INVOICE= +# --- Ladill Events (registration for webinars & conferences) --- +LADILL_EVENTS_APP_URL=https://events.ladill.com +LADILL_EVENTS_API_URL=https://events.ladill.com/api/service/v1 + # --- Ladill Mail calendar (auto-sync scheduled meetings) --- LADILL_MAIL_API_URL=https://mail.ladill.com/api/service/v1 MAIL_API_KEY_MEET= diff --git a/app/Http/Controllers/Meet/ConferenceController.php b/app/Http/Controllers/Meet/ConferenceController.php index 4f1b838..710f895 100644 --- a/app/Http/Controllers/Meet/ConferenceController.php +++ b/app/Http/Controllers/Meet/ConferenceController.php @@ -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'], diff --git a/app/Http/Controllers/Meet/EventsLinkController.php b/app/Http/Controllers/Meet/EventsLinkController.php new file mode 100644 index 0000000..d7c9627 --- /dev/null +++ b/app/Http/Controllers/Meet/EventsLinkController.php @@ -0,0 +1,86 @@ +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.'); + } +} diff --git a/app/Http/Controllers/Meet/InvitationController.php b/app/Http/Controllers/Meet/InvitationController.php index c080522..aa6fddc 100644 --- a/app/Http/Controllers/Meet/InvitationController.php +++ b/app/Http/Controllers/Meet/InvitationController.php @@ -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) diff --git a/app/Http/Controllers/Meet/JoinController.php b/app/Http/Controllers/Meet/JoinController.php index 8d4db68..601f9a1 100644 --- a/app/Http/Controllers/Meet/JoinController.php +++ b/app/Http/Controllers/Meet/JoinController.php @@ -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)) { diff --git a/app/Http/Controllers/Meet/WebinarController.php b/app/Http/Controllers/Meet/WebinarController.php index 89a8d55..3f62977 100644 --- a/app/Http/Controllers/Meet/WebinarController.php +++ b/app/Http/Controllers/Meet/WebinarController.php @@ -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 diff --git a/app/Http/Controllers/Meet/WebinarRegistrationController.php b/app/Http/Controllers/Meet/WebinarRegistrationController.php index 4ea31eb..abd77ac 100644 --- a/app/Http/Controllers/Meet/WebinarRegistrationController.php +++ b/app/Http/Controllers/Meet/WebinarRegistrationController.php @@ -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.']); + } } diff --git a/app/Models/Room.php b/app/Models/Room.php index ea1eab0..999d6a7 100644 --- a/app/Models/Room.php +++ b/app/Models/Room.php @@ -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(); + } } diff --git a/app/Services/Integrations/EventsClient.php b/app/Services/Integrations/EventsClient.php new file mode 100644 index 0000000..c737eff --- /dev/null +++ b/app/Services/Integrations/EventsClient.php @@ -0,0 +1,81 @@ +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> + */ + public function linkableEvents(string $ownerRef): array + { + $response = $this->get('events', ['owner_ref' => $ownerRef]); + + return (array) ($response['events'] ?? []); + } + + /** + * @param array $payload + * @return array + */ + public function linkRoom(int $eventId, array $payload): array + { + $response = $this->post("events/{$eventId}/link-room", $payload); + + return (array) ($response['event'] ?? []); + } + + /** + * @return array + */ + 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 + */ + 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(); + } +} diff --git a/app/Services/Integrations/EventsLinkService.php b/app/Services/Integrations/EventsLinkService.php new file mode 100644 index 0000000..938492a --- /dev/null +++ b/app/Services/Integrations/EventsLinkService.php @@ -0,0 +1,63 @@ +> + */ + 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(); + } +} diff --git a/app/Support/EventsSourceLink.php b/app/Support/EventsSourceLink.php index 83aed9a..c4c35c2 100644 --- a/app/Support/EventsSourceLink.php +++ b/app/Support/EventsSourceLink.php @@ -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 */ + 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; } } diff --git a/config/meet.php b/config/meet.php index c95a4f0..e1d6493 100644 --- a/config/meet.php +++ b/config/meet.php @@ -161,6 +161,8 @@ return [ ], 'events_app_url' => env('LADILL_EVENTS_APP_URL', 'https://events.ladill.com'), + 'events_api_url' => env('LADILL_EVENTS_API_URL', 'https://events.ladill.com/api/service/v1'), + 'events_api_key' => env('MEET_API_KEY_EVENTS'), 'calendar' => [ 'google' => [ diff --git a/resources/views/meet/conferences/create.blade.php b/resources/views/meet/conferences/create.blade.php index 49b829f..b46297e 100644 --- a/resources/views/meet/conferences/create.blade.php +++ b/resources/views/meet/conferences/create.blade.php @@ -1,7 +1,7 @@

Schedule a conference

-

Large-format events with assigned hosts and speakers. Optional green room for presenters before going live.

+

After creating the conference, link it to a virtual or hybrid event in Ladill Events for registration, speakers, invitations, and QR codes. Optional green room for presenters before going live.

@csrf @@ -36,26 +36,6 @@
-
- - -
- - @if ($members->isNotEmpty()) -
- -

Team members who can speak on stage. You are always the host.

-
- @foreach ($members as $member) - - @endforeach -
-
- @endif -
diff --git a/resources/views/meet/conferences/show.blade.php b/resources/views/meet/conferences/show.blade.php index 3700467..f13afaf 100644 --- a/resources/views/meet/conferences/show.blade.php +++ b/resources/views/meet/conferences/show.blade.php @@ -4,11 +4,6 @@

Conference

{{ $room->title }}

{{ config('meet.room_statuses')[$room->status] ?? $room->status }}

- @if(! empty($eventsEventUrl)) - - View in Ladill Events → - - @endif
@if ($errors->any()) @@ -17,6 +12,14 @@ @endif + @if (session('success')) +
+ {{ session('success') }} +
+ @endif + + @include('meet.partials.events-integration', ['room' => $room]) +

Join link

@@ -39,11 +42,9 @@ @endif Download iCal - QR code @if ($room->sessions->isNotEmpty()) Attendance CSV @endif - Manage invitations
@if ($room->passcode) @@ -59,78 +60,18 @@ @if ($room->setting('green_room'))

Green room is on — when you start, you and assigned speakers rehearse before going live to the audience.

@endif -
-
-

Speakers

-

Team members on stage and external speakers invited by email. You are always the host.

- - @if ($speakers->isNotEmpty() || $room->invitations->where('role', 'panelist')->isNotEmpty()) -
- @if ($speakers->isNotEmpty()) -
-

Assigned speakers

-
    - @foreach ($speakers as $speaker) - @php $user = $speakerUsers->get($speaker->user_ref); @endphp -
  • {{ $user?->name ?? $speaker->user_ref }}
  • - @endforeach -
-
- @endif - - @if ($room->invitations->where('role', 'panelist')->isNotEmpty()) -
-

Invited speakers

-
    - @foreach ($room->invitations->where('role', 'panelist') as $invite) -
  • - {{ $invite->display_name ?? $invite->email }} - · {{ ucfirst($invite->status) }} -
  • - @endforeach -
-
- @endif -
- @else -

No speakers assigned yet.

- @endif - - @if ($canManageSpeakers) -
+ @if ($room->status === 'scheduled') + @csrf - -
- - -

Comma-separated. Invitations are sent as speaker (panelist) roles.

-
- - @if ($members->isNotEmpty()) -
- -
- @foreach ($members as $member) - @php $user = $memberUsers->get($member->user_ref); @endphp - - @endforeach -
-
- @endif - -
diff --git a/resources/views/meet/events/link.blade.php b/resources/views/meet/events/link.blade.php new file mode 100644 index 0000000..e035042 --- /dev/null +++ b/resources/views/meet/events/link.blade.php @@ -0,0 +1,61 @@ + +
+ ← Back +

Link to Ladill Events

+

+ Choose a virtual or hybrid event. Registration, bulk invitations, speakers, and QR codes will be managed in Events. +

+ + @if ($errors->any()) +
+ {{ $errors->first() }} +
+ @endif + + @if ($events === []) +
+

No virtual or hybrid events found in your Ladill Events account.

+ Create event in Events +
+ @else +
+ @csrf + +
+ + +
+ +
+ +

Pick an empty session matching this {{ $room->isConference() ? 'conference' : 'webinar' }}, or leave blank to attach to the first available {{ $expectedType === 'town_hall' ? 'conference' : 'webinar' }} slot.

+ +
+ + +
+ @endif +
+
diff --git a/resources/views/meet/partials/events-integration.blade.php b/resources/views/meet/partials/events-integration.blade.php new file mode 100644 index 0000000..d137b96 --- /dev/null +++ b/resources/views/meet/partials/events-integration.blade.php @@ -0,0 +1,79 @@ +@php + use App\Support\EventsSourceLink; + + $eventsUrls = EventsSourceLink::urls($room); + $isLinked = EventsSourceLink::isLinked($room); + $eventTitle = EventsSourceLink::eventTitle($room); + $eventsBase = EventsSourceLink::baseUrl(); + $kind = $room->isConference() ? 'conference' : 'webinar'; +@endphp + +
+
+
+

Ladill Events

+ @if ($isLinked && $eventTitle) +

Linked to {{ $eventTitle }}

+ @elseif ($isLinked) +

Linked to event #{{ EventsSourceLink::eventId($room) }}

+ @else +

+ Registration, invitations, speakers, and QR codes for this {{ $kind }} are managed in Ladill Events. + Link a virtual or hybrid event to enable attendee registration. +

+ @endif +
+ @if ($isLinked) +
+ @csrf + @method('DELETE') + +
+ @endif +
+ + @if ($isLinked) +
+ @if ($eventsUrls['registration']) +
+

Registration page

+
+ + +
+
+ @endif + @if ($eventsUrls['admin']) + + Attendees & check-in → + + @endif + @if ($eventsUrls['edit']) + + Event settings (tickets, branding) → + + @endif + @if ($eventsUrls['comms']) + + Bulk invitations & comms → + + @endif + @if ($eventsUrls['qr']) + + Event QR code → + + @endif + @if ($eventsUrls['badges']) + + Badges & printing → + + @endif +
+

Programme speakers and panelists are configured on the event programme in Events and sync to this session when linked.

+ @else + + @endif +
diff --git a/resources/views/meet/webinars/create.blade.php b/resources/views/meet/webinars/create.blade.php index 5eb3aff..2471343 100644 --- a/resources/views/meet/webinars/create.blade.php +++ b/resources/views/meet/webinars/create.blade.php @@ -1,7 +1,7 @@

Schedule a webinar

-

Attendees register in advance. Billed at GHS {{ number_format(config('meet.billing.price_per_participant_ghs', 0.30), 2) }} per participant when the session ends.

+

After creating the webinar, link it to a virtual or hybrid event in Ladill Events for registration, invitations, and QR codes. Billed at GHS {{ number_format(config('meet.billing.price_per_participant_ghs', 0.30), 2) }} per participant when the session ends.

@csrf @@ -36,11 +36,6 @@
-
- - -
-
@@ -52,14 +47,13 @@ Waiting room - Hold registrants until you admit them. + Hold attendees until you admit them. - diff --git a/resources/views/meet/webinars/show.blade.php b/resources/views/meet/webinars/show.blade.php index d776c3b..effcd47 100644 --- a/resources/views/meet/webinars/show.blade.php +++ b/resources/views/meet/webinars/show.blade.php @@ -5,11 +5,6 @@

Webinar

{{ $room->title }}

{{ config('meet.room_statuses')[$room->status] ?? $room->status }}

- @if(! empty($eventsEventUrl)) - - View in Ladill Events → - - @endif
@if ($room->canRestart()) @@ -35,14 +30,10 @@ Billed at GHS {{ number_format(config('meet.billing.price_per_participant_ghs', 0.30), 2) }} per participant (peak attendance when the session ends). -
-

Registration link

-
- - -
+ @include('meet.partials.events-integration', ['room' => $room]) -

Join link (hosts & panelists)

+
+

Join link (hosts & panelists)

@@ -50,19 +41,15 @@
Download iCal - QR code @if ($room->sessions->isNotEmpty()) Attendance CSV @endif - Manage invitations @if ($room->canRestart() && ($room->status === 'ended' || $room->sessions->isNotEmpty())) @csrf @endif - Registrations - Registration page
@if ($room->passcode) @@ -122,26 +109,5 @@
@endif - - @if ($room->sessions->isNotEmpty()) -
-

Sessions

-
    - @foreach ($room->sessions as $session) -
  • - {{ $session->started_at?->format('M j, Y g:i A') ?? 'Pending' }} - {{ $session->peak_participants }} participants · {{ config('meet.session_statuses')[$session->status] ?? $session->status }} -
  • - @endforeach -
-
- @endif - - @if ($room->status === 'scheduled') -
- @csrf - -
- @endif
diff --git a/routes/web.php b/routes/web.php index b1f87d4..21772ff 100644 --- a/routes/web.php +++ b/routes/web.php @@ -143,6 +143,10 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::post('/conferences/{room}/speakers', [ConferenceController::class, 'updateSpeakers'])->name('meet.conferences.speakers.update'); Route::post('/conferences/{room}/start', [ConferenceController::class, 'start'])->name('meet.conferences.start'); + Route::get('/meetings/{room}/events-link', [\App\Http\Controllers\Meet\EventsLinkController::class, 'show'])->name('meet.events.link'); + Route::post('/meetings/{room}/events-link', [\App\Http\Controllers\Meet\EventsLinkController::class, 'store'])->name('meet.events.link.store'); + Route::delete('/meetings/{room}/events-link', [\App\Http\Controllers\Meet\EventsLinkController::class, 'destroy'])->name('meet.events.link.destroy'); + Route::get('/rooms', [SpaceController::class, 'index'])->name('meet.spaces.index'); Route::get('/rooms/create', [SpaceController::class, 'create'])->name('meet.spaces.create'); Route::post('/rooms', [SpaceController::class, 'store'])->name('meet.spaces.store'); diff --git a/tests/Feature/MeetWebTest.php b/tests/Feature/MeetWebTest.php index 39f77e4..b0b33f3 100644 --- a/tests/Feature/MeetWebTest.php +++ b/tests/Feature/MeetWebTest.php @@ -630,23 +630,14 @@ class MeetWebTest extends TestCase ]); } - public function test_webinar_registration_flow(): void + public function test_webinar_registration_redirects_without_events_link(): void { - $room = $this->createRoom([ - 'type' => 'webinar', - 'settings' => array_merge(config('meet.default_settings'), ['webinar_auto_approve' => true]), - ]); + $room = $this->createRoom(['type' => 'webinar']); $this->post('/w/'.$room->uuid.'/register', [ 'email' => 'attendee@example.com', 'display_name' => 'Attendee One', - ])->assertOk(); - - $this->assertDatabaseHas('meet_webinar_registrations', [ - 'room_id' => $room->id, - 'email' => 'attendee@example.com', - 'status' => 'approved', - ]); + ])->assertRedirect(route('meet.webinars.show', $room)); } public function test_host_can_create_webinar_room(): void