From 799c302e2a24c8f7826e47e5c20dba58dcd31f93 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Wed, 1 Jul 2026 20:12:57 +0000 Subject: [PATCH] Improve conferences, guest entry, Afia, and cross-app scheduling. Route guests through silent SSO to the Meet product page, fix Afia context query, add conference green-room UX and copy, and extend service API for Care/CRM/Invoice calendar integration. Co-authored-by: Cursor --- .env.example | 6 +- .../Api/V1/ServiceRoomController.php | 6 +- .../Controllers/Auth/SsoLoginController.php | 6 +- app/Http/Controllers/Meet/AiController.php | 2 +- .../Controllers/Meet/ConferenceController.php | 27 +++- app/Http/Controllers/Meet/JoinController.php | 24 +++- .../Meet/MeetingFeaturesController.php | 26 +++- .../Meet/MeetingRoomController.php | 51 ++++++- .../Meet/Calendar/MailCalendarProvider.php | 25 +++- app/Services/Meet/CalendarService.php | 41 ++++-- app/Services/Meet/ConferenceService.php | 87 ++++++++++++ app/Services/Meet/ParticipantPresenter.php | 1 + app/Services/Meet/SecurityPolicyService.php | 9 +- config/ladill.php | 2 +- config/meet.php | 11 ++ resources/js/meet-room.js | 74 +++++++++- .../views/meet/conferences/create.blade.php | 19 ++- .../views/meet/conferences/show.blade.php | 34 +++-- resources/views/meet/ended/show.blade.php | 6 +- resources/views/meet/join/cancelled.blade.php | 4 +- resources/views/meet/join/denied.blade.php | 2 +- resources/views/meet/join/passcode.blade.php | 4 +- resources/views/meet/join/show.blade.php | 7 +- resources/views/meet/left/show.blade.php | 4 +- resources/views/meet/left/thanks.blade.php | 4 +- .../views/meet/partials/room-card.blade.php | 2 + .../views/meet/recordings/show.blade.php | 11 +- resources/views/meet/room/show.blade.php | 58 ++++++-- resources/views/meet/rooms/create.blade.php | 2 +- .../views/meet/webinars/create.blade.php | 2 +- routes/web.php | 3 +- tests/Feature/MeetWebTest.php | 130 ++++++++++++++++++ 32 files changed, 601 insertions(+), 89 deletions(-) create mode 100644 app/Services/Meet/ConferenceService.php diff --git a/.env.example b/.env.example index 4f69c45..f66cac0 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,7 @@ APP_URL=https://meet.ladill.com PLATFORM_URL=https://ladill.com PLATFORM_DOMAIN=ladill.com +LADILL_MARKETING_URL=https://ladill.com/products/meet LOG_CHANNEL=stack LOG_LEVEL=debug @@ -59,8 +60,11 @@ MEET_API_KEY_EVENTS= MEET_API_KEY_FRONTDESK= MEET_API_KEY_HR= MEET_API_KEY_SUPPORT= +MEET_API_KEY_INVOICE= -# --- Calendar OAuth (optional) --- +# --- Ladill Mail calendar (auto-sync scheduled meetings) --- +LADILL_MAIL_API_URL=https://mail.ladill.com/api/service/v1 +MAIL_API_KEY_MEET= GOOGLE_CALENDAR_CLIENT_ID= GOOGLE_CALENDAR_CLIENT_SECRET= GOOGLE_CALENDAR_REDIRECT_URI=https://meet.ladill.com/settings/calendar/callback diff --git a/app/Http/Controllers/Api/V1/ServiceRoomController.php b/app/Http/Controllers/Api/V1/ServiceRoomController.php index 141e63a..82fcedb 100644 --- a/app/Http/Controllers/Api/V1/ServiceRoomController.php +++ b/app/Http/Controllers/Api/V1/ServiceRoomController.php @@ -28,7 +28,7 @@ class ServiceRoomController extends Controller { $validated = $request->validate([ 'owner_ref' => ['required', 'string'], - 'organization_id' => ['required', 'integer', 'exists:meet_organizations,id'], + 'organization_id' => ['nullable', 'integer', 'exists:meet_organizations,id'], 'host_user_ref' => ['required', 'string'], 'title' => ['required', 'string', 'max:255'], 'description' => ['nullable', 'string'], @@ -44,7 +44,9 @@ class ServiceRoomController extends Controller 'invite_emails.*' => ['email'], ]); - $organization = Organization::findOrFail($validated['organization_id']); + $organization = isset($validated['organization_id']) + ? Organization::findOrFail($validated['organization_id']) + : Organization::owned($validated['owner_ref'])->firstOrFail(); abort_unless($organization->owner_ref === $validated['owner_ref'], 403); $host = User::where('public_id', $validated['host_user_ref'])->firstOrFail(); diff --git a/app/Http/Controllers/Auth/SsoLoginController.php b/app/Http/Controllers/Auth/SsoLoginController.php index d06cbe3..6f16d83 100644 --- a/app/Http/Controllers/Auth/SsoLoginController.php +++ b/app/Http/Controllers/Auth/SsoLoginController.php @@ -88,11 +88,7 @@ class SsoLoginController extends Controller if ($request->filled('error')) { if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true) && ! $request->boolean('interactive')) { - return redirect()->route('sso.connect', [ - 'redirect' => $intended, - 'interactive' => 1, - 'fallback' => 1, - ]); + return redirect()->away((string) config('ladill.marketing_url')); } return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error'))); diff --git a/app/Http/Controllers/Meet/AiController.php b/app/Http/Controllers/Meet/AiController.php index e221b31..a0fed7d 100644 --- a/app/Http/Controllers/Meet/AiController.php +++ b/app/Http/Controllers/Meet/AiController.php @@ -52,7 +52,7 @@ class AiController extends Controller 'meetings_upcoming' => Room::owned($owner) ->where('organization_id', $organization->id) ->where('status', 'scheduled') - ->where('starts_at', '>=', now()) + ->where('scheduled_at', '>=', now()) ->count(), 'recordings_total' => Recording::owned($owner) ->whereHas('session.room', fn ($q) => $q->where('organization_id', $organization->id)) diff --git a/app/Http/Controllers/Meet/ConferenceController.php b/app/Http/Controllers/Meet/ConferenceController.php index d6e301d..39aecff 100644 --- a/app/Http/Controllers/Meet/ConferenceController.php +++ b/app/Http/Controllers/Meet/ConferenceController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Meet; use App\Http\Controllers\Controller; use App\Http\Controllers\Meet\Concerns\ScopesToAccount; +use App\Models\Member; use App\Models\Room; use App\Models\Template; use App\Services\Meet\CalendarService; @@ -56,9 +57,16 @@ class ConferenceController extends Controller ->orderBy('name') ->get(); + $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, 'templates' => $templates, + 'members' => $members, 'timezones' => timezone_identifiers_list(), 'defaultSettings' => config('meet.default_settings'), ]); @@ -78,6 +86,8 @@ class ConferenceController extends Controller 'passcode' => ['nullable', 'string', 'max:20'], 'template_id' => ['nullable', 'integer', 'exists:meet_templates,id'], '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'], @@ -87,6 +97,12 @@ class ConferenceController extends Controller 'practice_mode' => ['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'), @@ -96,6 +112,7 @@ class ConferenceController extends Controller 'green_room' => $request->boolean('green_room', true), 'practice_mode' => $request->boolean('practice_mode'), 'stage_mode' => true, + 'presenter_refs' => $presenterRefs, ]; $data = array_merge($validated, [ @@ -115,7 +132,7 @@ class ConferenceController extends Controller if ($emails = trim((string) ($validated['invite_emails'] ?? ''))) { $invites = collect(preg_split('/[\s,;]+/', $emails)) ->filter() - ->map(fn ($email) => ['email' => $email]) + ->map(fn ($email) => ['email' => $email, 'role' => 'panelist']) ->all(); $this->invitations->inviteToRoom($room, $invites, $request->user()); } @@ -135,7 +152,13 @@ class ConferenceController extends Controller $room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles']); - return view('meet.conferences.show', compact('room')); + $presenterRefs = (array) $room->setting('presenter_refs', []); + $speakers = Member::owned($this->ownerRef($request)) + ->where('organization_id', $room->organization_id) + ->whereIn('user_ref', $presenterRefs) + ->get(); + + return view('meet.conferences.show', compact('room', 'speakers')); } public function start(Request $request, Room $room): RedirectResponse diff --git a/app/Http/Controllers/Meet/JoinController.php b/app/Http/Controllers/Meet/JoinController.php index 3659980..8d4db68 100644 --- a/app/Http/Controllers/Meet/JoinController.php +++ b/app/Http/Controllers/Meet/JoinController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Models\Participant; use App\Models\Room; use App\Models\Session; +use App\Services\Meet\ConferenceService; use App\Services\Meet\InvitationService; use App\Services\Meet\SecurityPolicyService; use App\Services\Meet\SessionService; @@ -24,6 +25,7 @@ class JoinController extends Controller protected SecurityPolicyService $security, protected WebinarService $webinars, protected SpaceService $spaces, + protected ConferenceService $conferences, ) {} public function show(Request $request, Room $room): View|RedirectResponse @@ -120,9 +122,11 @@ class JoinController extends Controller $displayName = $user?->name ?? ($validated['display_name'] ?? 'Guest'); $email = $user?->email ?? ($validated['email'] ?? null); + $kind = $room->isConference() ? 'conference' : 'meeting'; + [$allowed, $reason] = $this->security->canJoin($room, $user, $email ?? $displayName.'@guest.local'); if (! $allowed && $reason !== 'passcode_required') { - return back()->withErrors(['join' => $reason ?? 'Unable to join meeting.']); + return back()->withErrors(['join' => $reason ?? "Unable to join {$kind}."]); } try { @@ -136,20 +140,20 @@ class JoinController extends Controller if (! $room->activeSession() && $user && $user->ownerRef() === $room->host_user_ref) { $session = $this->sessions->start($room, $user); } elseif (! $room->activeSession()) { - return back()->withErrors(['join' => 'Meeting has not started yet. Please wait for the host.']); + return back()->withErrors(['join' => ucfirst($kind).' has not started yet. Please wait for the host.']); } else { $session = $room->activeSession(); } } else { if (! $room->activeSession()) { - return back()->withErrors(['join' => 'Meeting has not started yet. Please wait for the host.']); + return back()->withErrors(['join' => ucfirst($kind).' has not started yet. Please wait for the host.']); } $session = $room->activeSession(); } } catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { if (in_array($e->getStatusCode(), [402, 503], true)) { - return back()->withErrors(['join' => $e->getMessage() ?: 'Unable to start meeting.']); + return back()->withErrors(['join' => $e->getMessage() ?: "Unable to start {$kind}."]); } throw $e; @@ -159,6 +163,8 @@ class JoinController extends Controller if ($room->isSpace()) { $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 @@ -174,10 +180,20 @@ class JoinController extends Controller $displayName = $reg->display_name; } + if ($room->isConference() && ! $this->conferences->canJoinSession($room, $session, $role)) { + return back()->withErrors([ + 'join' => 'The conference has not gone live yet. Assigned speakers can join from the green room link when the host starts.', + ]); + } + $joinStatus = $room->requiresWaitingRoom($user, $role) ? 'waiting' : 'joined'; $participant = $this->sessions->joinParticipant($session, $user, $role, $displayName, $email, $joinStatus); + if ($room->isConference() && $session->session_mode === 'green_room') { + $this->conferences->registerPresenter($session, $user, $role); + } + app(InvitationService::class)->markAcceptedOnJoin($room, $user, $email); $this->sessions->rememberParticipantSession($request, $participant, $session); diff --git a/app/Http/Controllers/Meet/MeetingFeaturesController.php b/app/Http/Controllers/Meet/MeetingFeaturesController.php index c414c2c..b2d50e2 100644 --- a/app/Http/Controllers/Meet/MeetingFeaturesController.php +++ b/app/Http/Controllers/Meet/MeetingFeaturesController.php @@ -282,8 +282,32 @@ class MeetingFeaturesController extends Controller { $this->hostOnly($request, $session); $live = $this->townHall->goLive($session, $request->user()); + $participant = app(SessionService::class)->joinedParticipantForUser($live, $request->user()); - return response()->json(['session_uuid' => $live->uuid, 'mode' => 'live']); + if ($participant) { + app(SessionService::class)->rememberParticipantSession($request, $participant, $live); + } + + return response()->json([ + 'session_uuid' => $live->uuid, + 'mode' => 'live', + 'room_url' => route('meet.room', $live), + ]); + } + + public function addPresenter(Request $request, Session $session): JsonResponse + { + $this->hostOnly($request, $session); + abort_unless($session->room->isTownHall(), 422); + + $validated = $request->validate(['user_ref' => ['required', 'string', 'max:64']]); + + $updated = $this->townHall->addPresenter($session, $validated['user_ref']); + + return response()->json([ + 'session' => $updated, + 'presenter_refs' => $updated->presenter_refs, + ]); } public function handoffCoHost(Request $request, Session $session): JsonResponse diff --git a/app/Http/Controllers/Meet/MeetingRoomController.php b/app/Http/Controllers/Meet/MeetingRoomController.php index fbe27fe..8e0d911 100644 --- a/app/Http/Controllers/Meet/MeetingRoomController.php +++ b/app/Http/Controllers/Meet/MeetingRoomController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Meet; use App\Http\Controllers\Controller; use App\Models\Participant; use App\Models\Recording; +use App\Models\Room; use App\Models\Session; use App\Services\Meet\ChatService; use App\Services\Meet\Media\MediaProviderInterface; @@ -37,8 +38,11 @@ class MeetingRoomController extends Controller return redirect()->route('meet.ended', $session); } + $room = $session->room; + $kind = $room->isConference() ? 'conference' : 'meeting'; + $participantUuid = $request->session()->get("meet.participant.{$session->uuid}"); - abort_unless($participantUuid, 403, 'Please join the meeting first.'); + abort_unless($participantUuid, 403, "Please join the {$kind} first."); $participant = Participant::where('uuid', $participantUuid) ->where('session_id', $session->id) @@ -48,7 +52,7 @@ class MeetingRoomController extends Controller return redirect()->route('meet.join.waiting', $session->room); } - abort_unless($participant->status === 'joined', 403, 'You are not in this meeting.'); + abort_unless($participant->status === 'joined', 403, "You are not in this {$kind}."); $token = $this->media->isConfigured() ? $this->sessions->generateMediaToken($participant) @@ -72,6 +76,10 @@ class MeetingRoomController extends Controller 'activeRecording' => $session->recordings()->where('status', 'recording')->first(), 'watermark' => $room->organization?->securitySetting('watermark_enabled', false), 'isWebinar' => $room->isWebinar() || $room->isConference() || $room->isSpace(), + 'isConference' => $room->isConference(), + 'isGreenRoom' => $session->session_mode === 'green_room', + 'sessionMode' => $session->session_mode, + 'presenterRefs' => (array) $session->presenter_refs, 'isAudioOnly' => $isAudioOnly, 'isSpace' => $room->isSpace(), 'canPublish' => $room->isSpace() @@ -91,10 +99,43 @@ class MeetingRoomController extends Controller $this->sessions->end($session, $participant->user_ref ?? $participant->uuid); $room = $session->room; - $route = $room->isWebinar() ? 'meet.webinars.show' : 'meet.rooms.show'; - return redirect()->route($route, $room) - ->with('success', $room->isWebinar() ? 'Webinar ended.' : 'Meeting ended.'); + return redirect()->route($this->roomShowRoute($room), $room) + ->with('success', $this->roomEndedMessage($room)); + } + + protected function roomShowRoute(Room $room): string + { + if ($room->isWebinar()) { + return 'meet.webinars.show'; + } + + if ($room->isConference()) { + return 'meet.conferences.show'; + } + + if ($room->isSpace()) { + return 'meet.spaces.show'; + } + + return 'meet.rooms.show'; + } + + protected function roomEndedMessage(Room $room): string + { + if ($room->isWebinar()) { + return 'Webinar ended.'; + } + + if ($room->isConference()) { + return 'Conference ended.'; + } + + if ($room->isSpace()) { + return 'Room closed.'; + } + + return 'Meeting ended.'; } public function leave(Request $request, Session $session): RedirectResponse diff --git a/app/Services/Meet/Calendar/MailCalendarProvider.php b/app/Services/Meet/Calendar/MailCalendarProvider.php index 82f9c8a..2de0ab1 100644 --- a/app/Services/Meet/Calendar/MailCalendarProvider.php +++ b/app/Services/Meet/Calendar/MailCalendarProvider.php @@ -17,6 +17,11 @@ class MailCalendarProvider return null; } + return $this->createEventForMailbox($mailbox, $room); + } + + public function createEventForMailbox(string $mailbox, Room $room): ?string + { $response = Http::withToken((string) config('meet.calendar.mail.api_key')) ->acceptJson() ->timeout(10) @@ -32,13 +37,18 @@ class MailCalendarProvider } public function updateEvent(CalendarConnection $connection, Room $room, User $host): bool + { + $mailbox = $connection->calendar_id ?? $host->email; + + return $mailbox ? $this->updateEventForMailbox($mailbox, $room) : false; + } + + public function updateEventForMailbox(string $mailbox, Room $room): bool { if (! $room->calendar_event_id) { return false; } - $mailbox = $connection->calendar_id ?? $host->email; - return Http::withToken((string) config('meet.calendar.mail.api_key')) ->acceptJson() ->timeout(10) @@ -47,6 +57,11 @@ class MailCalendarProvider } public function deleteEvent(CalendarConnection $connection, Room $room): bool + { + return $this->deleteEventForMailbox($connection->calendar_id, $room); + } + + public function deleteEventForMailbox(?string $mailbox, Room $room): bool { if (! $room->calendar_event_id) { return false; @@ -55,10 +70,10 @@ class MailCalendarProvider return Http::withToken((string) config('meet.calendar.mail.api_key')) ->acceptJson() ->timeout(10) - ->delete($this->baseUrl().'/calendar/events/'.$room->calendar_event_id, [ - 'mailbox_email' => $connection->calendar_id, + ->delete($this->baseUrl().'/calendar/events/'.$room->calendar_event_id, array_filter([ + 'mailbox_email' => $mailbox ? strtolower(trim($mailbox)) : null, 'external_ref' => $room->uuid, - ]) + ])) ->successful(); } diff --git a/app/Services/Meet/CalendarService.php b/app/Services/Meet/CalendarService.php index ec1f2c0..51e0887 100644 --- a/app/Services/Meet/CalendarService.php +++ b/app/Services/Meet/CalendarService.php @@ -20,35 +20,54 @@ class CalendarService public function syncRoomCreated(Room $room, User $host): void { - $connection = $this->connectionFor($host); - if (! $connection) { + if (! $room->scheduled_at) { return; } - $eventId = $this->provider($connection)->createEvent($connection, $room, $host); - if ($eventId) { - $room->update(['calendar_event_id' => $eventId]); + if ($host->email) { + $eventId = $this->mail->createEventForMailbox($host->email, $room); + if ($eventId) { + $room->update(['calendar_event_id' => $eventId]); + } + } + + $connection = $this->connectionFor($host); + if ($connection && in_array($connection->provider, ['google', 'microsoft'], true)) { + $this->provider($connection)->createEvent($connection, $room, $host); } } public function syncRoomUpdated(Room $room, User $host): void { - $connection = $this->connectionFor($host); - if (! $connection || ! $room->calendar_event_id) { + if (! $room->scheduled_at) { return; } - $this->provider($connection)->updateEvent($connection, $room, $host); + if ($host->email) { + if ($room->calendar_event_id) { + $this->mail->updateEventForMailbox($host->email, $room); + } else { + $this->syncRoomCreated($room, $host); + } + } + + $connection = $this->connectionFor($host); + if ($connection && $room->calendar_event_id && in_array($connection->provider, ['google', 'microsoft'], true)) { + $this->provider($connection)->updateEvent($connection, $room, $host); + } } public function syncRoomCancelled(Room $room, User $host): void { + if ($host->email && $room->calendar_event_id) { + $this->mail->deleteEventForMailbox($host->email, $room); + } + $connection = $this->connectionFor($host); - if (! $connection || ! $room->calendar_event_id) { - return; + if ($connection && $room->calendar_event_id && in_array($connection->provider, ['google', 'microsoft'], true)) { + $this->provider($connection)->deleteEvent($connection, $room); } - $this->provider($connection)->deleteEvent($connection, $room); $room->update(['calendar_event_id' => null]); } diff --git a/app/Services/Meet/ConferenceService.php b/app/Services/Meet/ConferenceService.php new file mode 100644 index 0000000..ec4e539 --- /dev/null +++ b/app/Services/Meet/ConferenceService.php @@ -0,0 +1,87 @@ + + */ + public function presenterRefs(Room $room): array + { + return array_values(array_unique(array_filter( + (array) $room->setting('presenter_refs', []), + ))); + } + + public function resolveJoinRole(Room $room, ?User $user, ?string $email, string $fallbackRole = 'guest'): string + { + if ($user && $user->ownerRef() === $room->host_user_ref) { + return 'host'; + } + + $presenterRefs = $this->presenterRefs($room); + + if ($user && in_array($user->ownerRef(), $presenterRefs, true)) { + return 'panelist'; + } + + if ($email && $this->invitationRole($room, $email) === 'panelist') { + return 'panelist'; + } + + if ($room->isTownHall()) { + return 'attendee'; + } + + return $fallbackRole; + } + + public function canJoinSession(Room $room, Session $session, string $role): bool + { + if ($session->session_mode !== 'green_room') { + return true; + } + + return in_array($role, ['host', 'co_host', 'panelist'], true); + } + + public function isPresenter(Session $session, Participant $participant): bool + { + if (in_array($participant->role, ['host', 'co_host', 'panelist'], true)) { + return true; + } + + $refs = (array) $session->presenter_refs; + + return $participant->user_ref && in_array($participant->user_ref, $refs, true); + } + + public function canPublish(Room $room, Participant $participant): bool + { + return in_array($participant->role, ['host', 'co_host', 'panelist'], true); + } + + public function registerPresenter(Session $session, ?User $user, string $role): void + { + if (! $user || ! in_array($role, ['host', 'co_host', 'panelist'], true)) { + return; + } + + app(TownHallService::class)->addPresenter($session, $user->ownerRef()); + } + + protected function invitationRole(Room $room, string $email): ?string + { + return Invitation::query() + ->where('room_id', $room->id) + ->where('email', strtolower(trim($email))) + ->value('role'); + } +} diff --git a/app/Services/Meet/ParticipantPresenter.php b/app/Services/Meet/ParticipantPresenter.php index b51ee19..fa3fe54 100644 --- a/app/Services/Meet/ParticipantPresenter.php +++ b/app/Services/Meet/ParticipantPresenter.php @@ -36,6 +36,7 @@ class ParticipantPresenter { return [ 'uuid' => $participant->uuid, + 'user_ref' => $participant->user_ref, 'display_name' => $participant->display_name, 'role' => $participant->role, 'status' => $participant->status, diff --git a/app/Services/Meet/SecurityPolicyService.php b/app/Services/Meet/SecurityPolicyService.php index f908442..1c34d5c 100644 --- a/app/Services/Meet/SecurityPolicyService.php +++ b/app/Services/Meet/SecurityPolicyService.php @@ -13,20 +13,21 @@ class SecurityPolicyService { $org = $room->organization; $policy = $this->mergedPolicy($org, $room); + $kind = $room->isConference() ? 'conference' : 'meeting'; if ($policy['org_only'] && ! $user) { - return [false, 'This meeting requires a Ladill account.']; + return [false, "This {$kind} requires a Ladill account."]; } if ($policy['authenticated_only'] && ! $user) { - return [false, 'Please sign in to join this meeting.']; + return [false, "Please sign in to join this {$kind}."]; } if (! empty($policy['allowed_domains']) && $email) { $domain = Str($email)->after('@')->lower()->toString(); $allowed = collect($policy['allowed_domains'])->map(fn ($d) => strtolower(trim($d)))->filter(); if ($allowed->isNotEmpty() && ! $allowed->contains($domain)) { - return [false, 'Your email domain is not allowed to join this meeting.']; + return [false, "Your email domain is not allowed to join this {$kind}."]; } } @@ -35,7 +36,7 @@ class SecurityPolicyService } if ($room->activeSession()?->is_locked && ! $this->isHost($room, $user)) { - return [false, 'This meeting is locked.']; + return [false, "This {$kind} is locked."]; } if ($policy['recording_host_only'] && ! $this->isHost($room, $user)) { diff --git a/config/ladill.php b/config/ladill.php index ce81526..ba52e96 100644 --- a/config/ladill.php +++ b/config/ladill.php @@ -2,5 +2,5 @@ return [ 'product_slug' => 'meet', - 'marketing_url' => env('LADILL_MARKETING_URL', 'https://ladill.com'), + 'marketing_url' => env('LADILL_MARKETING_URL', 'https://ladill.com/products/meet'), ]; diff --git a/config/meet.php b/config/meet.php index a6b5001..5fbe2e0 100644 --- a/config/meet.php +++ b/config/meet.php @@ -41,6 +41,16 @@ return [ 'monthly' => 'Monthly', ], + 'schedule_timezones' => [ + 'UTC', + 'America/New_York', + 'America/Chicago', + 'America/Los_Angeles', + 'Europe/London', + 'Africa/Accra', + 'Africa/Lagos', + ], + 'participant_roles' => [ 'host' => 'Host', 'co_host' => 'Co-host', @@ -139,6 +149,7 @@ return [ 'frontdesk' => env('MEET_API_KEY_FRONTDESK'), 'hr' => env('MEET_API_KEY_HR'), 'support' => env('MEET_API_KEY_SUPPORT'), + 'invoice' => env('MEET_API_KEY_INVOICE'), ], 'webhook_events' => [ diff --git a/resources/js/meet-room.js b/resources/js/meet-room.js index bc7a6fc..26ba306 100644 --- a/resources/js/meet-room.js +++ b/resources/js/meet-room.js @@ -38,6 +38,10 @@ function meetRoom() { needsMediaUnlock: false, waitingCount: 0, isWebinar: false, + isConference: false, + isGreenRoom: false, + goingLive: false, + presenterRefs: [], audioOnly: false, isSpace: false, qaItems: [], @@ -96,6 +100,8 @@ function meetRoom() { filesUploadUrl: el.dataset.filesUploadUrl, whiteboardUrl: el.dataset.whiteboardUrl, whiteboardSaveUrl: el.dataset.whiteboardSaveUrl, + goLiveUrl: el.dataset.goLiveUrl, + addPresenterUrl: el.dataset.addPresenterUrl, csrf: el.dataset.csrf, sessionUuid: el.dataset.sessionUuid, }; @@ -107,6 +113,8 @@ function meetRoom() { this.isHost = el.dataset.isHost === '1'; this.canPublish = el.dataset.canPublish !== '0'; this.isWebinar = el.dataset.isWebinar === '1'; + this.isConference = el.dataset.isConference === '1'; + this.isGreenRoom = el.dataset.isGreenRoom === '1'; this.audioOnly = el.dataset.audioOnly === '1'; this.isSpace = el.dataset.isSpace === '1'; this.muteOnJoin = el.dataset.muteOnJoin === '1'; @@ -117,6 +125,12 @@ function meetRoom() { this.liveCaptions = el.dataset.liveCaptions === '1'; this.displayName = el.dataset.displayName || ''; + try { + this.presenterRefs = JSON.parse(el.dataset.presenterRefs || '[]'); + } catch { + this.presenterRefs = []; + } + try { this.sessionParticipants = JSON.parse(el.dataset.initialParticipants || '[]'); } catch { @@ -888,10 +902,10 @@ function meetRoom() { if (blob && recordingUuid) { const uploaded = await this.uploadRecording(blob, recordingUuid); if (!uploaded) { - window.alert('Recording upload failed. Open Recordings and try again, or re-record the meeting.'); + window.alert(`Recording upload failed. Open Recordings and try again, or re-record the ${this.sessionNoun()}.`); } } else if (recordingUuid) { - window.alert('No recording was captured from this meeting. Start recording again while the meeting is live.'); + window.alert(`No recording was captured from this ${this.sessionNoun()}. Start recording again while the ${this.sessionNoun()} is live.`); } this.activeRecordingUuid = null; @@ -1202,6 +1216,52 @@ function meetRoom() { }); }, + async goLive() { + if (!this.config.goLiveUrl || this.goingLive) { + return; + } + + this.goingLive = true; + + try { + const res = await fetch(this.config.goLiveUrl, { + method: 'POST', + headers: this.headers(), + }); + + if (!res.ok) { + throw new Error('Go live failed'); + } + + const data = await res.json(); + window.location.href = data.room_url || `/room/${data.session_uuid}`; + } catch { + this.goingLive = false; + } + }, + + async makeSpeaker(userRef) { + if (!this.config.addPresenterUrl || !userRef) { + return; + } + + const res = await fetch(this.config.addPresenterUrl, { + method: 'POST', + headers: this.headers(), + body: JSON.stringify({ user_ref: userRef }), + }); + + if (! res.ok) { + return; + } + + const data = await res.json(); + this.presenterRefs = data.presenter_refs || this.presenterRefs; + this.sessionParticipants = this.sessionParticipants.map((person) => ( + person.user_ref === userRef ? { ...person, role: 'panelist' } : person + )); + }, + toggleChat() { this.chatOpen = !this.chatOpen; if (this.chatOpen) { @@ -1238,7 +1298,7 @@ function meetRoom() { try { await navigator.share({ title: this.roomTitle || 'Ladill Meet', - text: 'Join my meeting on Ladill Meet', + text: `Join my ${this.sessionNoun()} on Ladill Meet`, url: this.joinUrl, }); @@ -1329,6 +1389,14 @@ function meetRoom() { } }, + sessionNoun() { + return this.isConference ? 'conference' : 'meeting'; + }, + + sessionNounTitle() { + return this.isConference ? 'Conference' : 'Meeting'; + }, + redirectToMeetingEnded(url) { if (this.pollTimer) { clearInterval(this.pollTimer); diff --git a/resources/views/meet/conferences/create.blade.php b/resources/views/meet/conferences/create.blade.php index 749d0f8..3d0aff8 100644 --- a/resources/views/meet/conferences/create.blade.php +++ b/resources/views/meet/conferences/create.blade.php @@ -41,7 +41,7 @@
@@ -49,10 +49,25 @@
- +
+ @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 c7b217e..36d5be2 100644 --- a/resources/views/meet/conferences/show.blade.php +++ b/resources/views/meet/conferences/show.blade.php @@ -12,14 +12,6 @@
@endif -
- Multi-speaker event with assigned hosts and presenters. - Billed at GHS {{ number_format(config('meet.billing.price_per_participant_ghs', 0.30), 2) }} per participant. - @if ($room->setting('green_room')) - Green room is enabled — presenters rehearse before the audience joins live. - @endif -
-

Join link

@@ -59,8 +51,30 @@

@endif - @if ($room->description) -

{{ $room->description }}

+ @if ($room->setting('green_room')) +

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

+ @endif + + @if ($speakers->isNotEmpty()) +
+

Assigned speakers

+
    + @foreach ($speakers as $speaker) +
  • {{ $speaker->user_ref }}
  • + @endforeach +
+
+ @endif + + @if ($room->invitations->where('role', 'panelist')->isNotEmpty()) +
+

Invited speakers

+
    + @foreach ($room->invitations->where('role', 'panelist') as $invite) +
  • {{ $invite->email }}
  • + @endforeach +
+
@endif
diff --git a/resources/views/meet/ended/show.blade.php b/resources/views/meet/ended/show.blade.php index 5a0aabd..43e35b9 100644 --- a/resources/views/meet/ended/show.blade.php +++ b/resources/views/meet/ended/show.blade.php @@ -1,4 +1,4 @@ - +
@@ -6,8 +6,8 @@
-

Meeting ended

-

The host has ended this meeting.

+

{{ $room->isConference() ? 'Conference ended' : 'Meeting ended' }}

+

The host has ended this {{ $room->isConference() ? 'conference' : 'meeting' }}.

{{ $room->title }}

diff --git a/resources/views/meet/join/cancelled.blade.php b/resources/views/meet/join/cancelled.blade.php index 646a4dd..4a107e9 100644 --- a/resources/views/meet/join/cancelled.blade.php +++ b/resources/views/meet/join/cancelled.blade.php @@ -1,8 +1,8 @@ - +

{{ $room->title }}

-

This meeting has been cancelled by the host.

+

This {{ $room->isConference() ? 'conference' : 'meeting' }} has been cancelled by the host.

diff --git a/resources/views/meet/join/denied.blade.php b/resources/views/meet/join/denied.blade.php index e413d00..9005157 100644 --- a/resources/views/meet/join/denied.blade.php +++ b/resources/views/meet/join/denied.blade.php @@ -7,7 +7,7 @@

Unable to join

-

{{ $reason ?? 'You do not have access to this meeting.' }}

+

{{ $reason ?? 'You do not have access to this '.($room->isConference() ? 'conference' : 'meeting').'.' }}

Sign in with Ladill diff --git a/resources/views/meet/join/passcode.blade.php b/resources/views/meet/join/passcode.blade.php index 70b8316..43127a4 100644 --- a/resources/views/meet/join/passcode.blade.php +++ b/resources/views/meet/join/passcode.blade.php @@ -1,8 +1,8 @@ - +

{{ $room->title }}

-

This meeting is protected. Enter the passcode to continue.

+

This {{ $room->isConference() ? 'conference' : 'meeting' }} is protected. Enter the passcode to continue.

@if ($errors->any())
diff --git a/resources/views/meet/join/show.blade.php b/resources/views/meet/join/show.blade.php index 7b7545d..1177386 100644 --- a/resources/views/meet/join/show.blade.php +++ b/resources/views/meet/join/show.blade.php @@ -1,4 +1,7 @@ + @php + $sessionNoun = $room->isConference() ? 'conference' : 'meeting'; + @endphp

{{ $room->title }}

@@ -47,11 +50,11 @@ @endauth @if ($room->setting('waiting_room', true) && ! ($user && $user->ownerRef() === $room->host_user_ref)) -

The host will admit you when the meeting is ready.

+

The host will admit you when the {{ $sessionNoun }} is ready.

@endif diff --git a/resources/views/meet/left/show.blade.php b/resources/views/meet/left/show.blade.php index 068dc54..c00cfe0 100644 --- a/resources/views/meet/left/show.blade.php +++ b/resources/views/meet/left/show.blade.php @@ -6,7 +6,7 @@ @csrf
-

You left the meeting

+

You left the {{ $room->isConference() ? 'conference' : 'meeting' }}

{{ $room->title }}

@if (! empty($feedback['display_name']))

Thanks, {{ $feedback['display_name'] }}.

@@ -14,7 +14,7 @@
-

How was the meeting overall?

+

How was the {{ $room->isConference() ? 'conference' : 'meeting' }} overall?

@foreach (range(1, 5) as $star)
diff --git a/resources/views/meet/partials/room-card.blade.php b/resources/views/meet/partials/room-card.blade.php index 9531e80..f45fda6 100644 --- a/resources/views/meet/partials/room-card.blade.php +++ b/resources/views/meet/partials/room-card.blade.php @@ -25,6 +25,8 @@

@if ($room->scheduled_at) {{ $room->scheduled_at->timezone($room->timezone)->format('M j, Y g:i A T') }} + @elseif ($room->isConference()) + Instant conference @else Instant meeting @endif diff --git a/resources/views/meet/recordings/show.blade.php b/resources/views/meet/recordings/show.blade.php index 0b3d9f5..74280ef 100644 --- a/resources/views/meet/recordings/show.blade.php +++ b/resources/views/meet/recordings/show.blade.php @@ -1,5 +1,12 @@ @php $room = $recording->session->room; + $sessionNoun = $room->isConference() ? 'conference' : 'meeting'; + $roomDetailsRoute = match (true) { + $room->isConference() => route('meet.conferences.show', $room), + $room->isWebinar() => route('meet.webinars.show', $room), + $room->isSpace() => route('meet.spaces.show', $room), + default => route('meet.rooms.show', $room), + }; $statusLabel = config('meet.recording_statuses')[$recording->status] ?? ucfirst($recording->status); $statusClass = match ($recording->status) { 'ready' => 'bg-emerald-50 text-emerald-700', @@ -80,7 +87,7 @@

Processing recording

-

This usually takes a moment after the meeting ends. Refresh shortly.

+

This usually takes a moment after the {{ $sessionNoun }} ends. Refresh shortly.

@elseif ($recording->status === 'failed') @@ -98,7 +105,7 @@ Download @endif - View meeting + View {{ $sessionNoun }} @if ($summary) AI summary @endif diff --git a/resources/views/meet/room/show.blade.php b/resources/views/meet/room/show.blade.php index 0b5e1a0..7938c07 100644 --- a/resources/views/meet/room/show.blade.php +++ b/resources/views/meet/room/show.blade.php @@ -24,6 +24,9 @@ 'display_name' => $roomHostUser?->name ?? 'Host', 'avatar_url' => $roomHostUser?->avatarUrl(), ]; + $isConferenceSession = $isConference ?? false; + $sessionNoun = $isConferenceSession ? 'conference' : 'meeting'; + $sessionNounTitle = $isConferenceSession ? 'Conference' : 'Meeting'; @endphp
{{ $room->title }} @if ($isAudioOnly ?? false)

Audio room

+ @elseif ($isConference ?? false) +

@endif

@@ -122,6 +135,18 @@

+
+
+

Green room

+

Only hosts and speakers are visible. Attendees join after you go live.

+
+ +
+
@@ -151,7 +176,7 @@

Invite others

-

Share this link so people can join the meeting.

+

Share this link so people can join the {{ $sessionNoun }}.

+ class="rounded-xl bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500">End {{ $sessionNoun }}
- +

Messages are visible to everyone in the call

@@ -403,6 +428,13 @@ Speaking + + +

Meeting tools and extras

+

{{ $sessionNounTitle }} tools and extras

@@ -441,7 +473,7 @@ @click="sendReaction('thumbs'); featuresOpen = false" /> -
@@ -465,7 +497,7 @@
- Save this meeting to the cloud + Save this {{ $sessionNoun }} to the cloud @@ -475,7 +507,7 @@ @include('meet.room.partials.meet-icon', ['icon' => 'lock', 'class' => 'h-5 w-5'])
- + Control who can join diff --git a/resources/views/meet/rooms/create.blade.php b/resources/views/meet/rooms/create.blade.php index 5eb6d31..89ff7cb 100644 --- a/resources/views/meet/rooms/create.blade.php +++ b/resources/views/meet/rooms/create.blade.php @@ -40,7 +40,7 @@
diff --git a/resources/views/meet/webinars/create.blade.php b/resources/views/meet/webinars/create.blade.php index f9dae0d..f4e8897 100644 --- a/resources/views/meet/webinars/create.blade.php +++ b/resources/views/meet/webinars/create.blade.php @@ -41,7 +41,7 @@
diff --git a/routes/web.php b/routes/web.php index d2d892b..c11d11d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -32,7 +32,7 @@ use Illuminate\Support\Facades\Route; Route::get('/', fn () => auth()->check() ? redirect()->route('meet.dashboard') - : redirect()->route('sso.connect', ['interactive' => 1]))->name('meet.root'); + : redirect()->route('sso.connect'))->name('meet.root'); Route::get('/login', [SsoLoginController::class, 'connect'])->name('login'); Route::get('/sso/connect', [SsoLoginController::class, 'connect'])->name('sso.connect'); @@ -99,6 +99,7 @@ Route::post('/room/{session}/live-stream/start', [MeetingFeaturesController::cla Route::post('/room/{session}/live-stream/stop', [MeetingFeaturesController::class, 'stopLiveStream'])->name('meet.room.livestream.stop'); Route::post('/room/{session}/town-hall/green-room', [MeetingFeaturesController::class, 'startGreenRoom'])->name('meet.room.townhall.green'); Route::post('/room/{session}/town-hall/go-live', [MeetingFeaturesController::class, 'goLiveTownHall'])->name('meet.room.townhall.live'); +Route::post('/room/{session}/town-hall/presenters', [MeetingFeaturesController::class, 'addPresenter'])->name('meet.room.townhall.presenters'); Route::post('/room/{session}/town-hall/handoff', [MeetingFeaturesController::class, 'handoffCoHost'])->name('meet.room.townhall.handoff'); Route::middleware(['auth', 'platform.session'])->group(function () { diff --git a/tests/Feature/MeetWebTest.php b/tests/Feature/MeetWebTest.php index ac9f396..6594856 100644 --- a/tests/Feature/MeetWebTest.php +++ b/tests/Feature/MeetWebTest.php @@ -67,6 +67,20 @@ class MeetWebTest extends TestCase $this->get('/dashboard')->assertRedirect(); } + public function test_guest_root_uses_silent_sso_not_interactive_auth(): void + { + $response = $this->get('/'); + + $response->assertRedirect(route('sso.connect')); + $this->assertStringNotContainsString('interactive=1', (string) $response->headers->get('Location')); + } + + public function test_sso_callback_sends_guest_without_session_to_marketing_page(): void + { + $this->get('/sso/callback?error=login_required') + ->assertRedirect(config('ladill.marketing_url')); + } + public function test_onboarded_user_can_view_dashboard(): void { $this->actingAs($this->user) @@ -464,6 +478,7 @@ class MeetWebTest extends TestCase public function test_service_api_can_create_room(): void { + \Illuminate\Support\Facades\Http::fake(); config(['meet.service_api_keys.care' => 'test-care-key']); $this->postJson('/api/service/v1/rooms', [ @@ -482,6 +497,51 @@ class MeetWebTest extends TestCase ]); } + public function test_service_api_resolves_organization_from_owner_ref(): void + { + \Illuminate\Support\Facades\Http::fake(); + config(['meet.service_api_keys.crm' => 'test-crm-key']); + + $this->postJson('/api/service/v1/rooms', [ + 'owner_ref' => $this->user->public_id, + 'host_user_ref' => $this->user->public_id, + 'title' => 'CRM follow-up', + 'scheduled_at' => now()->addDay()->toIso8601String(), + 'source' => ['app' => 'crm', 'entity_type' => 'deal', 'entity_id' => '42'], + ], ['Authorization' => 'Bearer test-crm-key']) + ->assertCreated() + ->assertJsonPath('room.organization_id', $this->organization->id); + } + + public function test_scheduled_room_syncs_to_ladill_mail_calendar(): void + { + \Illuminate\Support\Facades\Http::fake([ + 'mail.ladill.com/*' => \Illuminate\Support\Facades\Http::response(['data' => ['id' => 99]], 201), + ]); + + config([ + 'meet.calendar.mail.api_url' => 'https://mail.ladill.com/api/service/v1', + 'meet.calendar.mail.api_key' => 'mail-test-key', + ]); + + $this->actingAs($this->user) + ->post('/meetings', [ + 'title' => 'Calendar sync test', + 'scheduled_at' => now()->addDay()->format('Y-m-d H:i'), + 'duration_minutes' => 30, + ]) + ->assertRedirect(); + + $room = \App\Models\Room::where('title', 'Calendar sync test')->first(); + $this->assertNotNull($room); + $this->assertSame('99', (string) $room->calendar_event_id); + + \Illuminate\Support\Facades\Http::assertSent(function ($request) { + return str_contains($request->url(), '/calendar/events') + && $request['mailbox_email'] === strtolower($this->user->email); + }); + } + public function test_invitation_rsvp_accepts(): void { $room = $this->createRoom(); @@ -614,6 +674,55 @@ class MeetWebTest extends TestCase ]); } + public function test_host_can_end_conference_and_view_details(): void + { + $room = Room::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'host_user_ref' => $this->user->public_id, + 'title' => 'NextGen Summit', + 'type' => 'town_hall', + 'status' => 'live', + 'scheduled_at' => now()->addHour(), + 'timezone' => 'UTC', + 'settings' => array_merge(config('meet.default_settings'), [ + 'green_room' => true, + 'stage_mode' => true, + 'presenter_refs' => [], + ]), + ]); + + $session = Session::create([ + 'owner_ref' => $this->user->public_id, + 'room_id' => $room->id, + 'media_room_name' => 'meet-'.$room->uuid, + 'status' => 'live', + 'session_mode' => 'live', + 'started_at' => now(), + ]); + + $participant = \App\Models\Participant::create([ + 'owner_ref' => $this->user->public_id, + 'session_id' => $session->id, + 'user_ref' => $this->user->public_id, + 'display_name' => $this->user->name, + 'role' => 'host', + 'status' => 'joined', + 'joined_at' => now(), + ]); + + $this->actingAs($this->user) + ->withSession(["meet.participant.{$session->uuid}" => $participant->uuid]) + ->post('/room/'.$session->uuid.'/end') + ->assertRedirect(route('meet.conferences.show', $room)) + ->assertSessionHas('success', 'Conference ended.'); + + $this->actingAs($this->user) + ->get(route('meet.conferences.show', $room)) + ->assertOk() + ->assertSee('NextGen Summit'); + } + public function test_host_can_create_space_room(): void { $this->actingAs($this->user) @@ -629,6 +738,27 @@ class MeetWebTest extends TestCase ]); } + public function test_afia_chat_returns_reply_when_ai_configured(): void + { + config([ + 'afia.api_key' => 'test-openai-key', + 'afia.platform_api_key' => '', + ]); + + Http::fake([ + 'api.openai.com/*' => Http::response([ + 'choices' => [['message' => ['content' => 'Go to Meetings and click Schedule meeting.']]], + ]), + ]); + + $this->createRoom(['title' => 'Upcoming sync']); + + $this->actingAs($this->user) + ->postJson('/ai/chat', ['message' => 'How do I schedule a meeting?']) + ->assertOk() + ->assertJson(['reply' => 'Go to Meetings and click Schedule meeting.']); + } + protected function createRoom(array $overrides = []): Room { return Room::create(array_merge([