Improve conferences, guest entry, Afia, and cross-app scheduling.
Deploy Ladill Meet / deploy (push) Successful in 50s

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 <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-01 20:12:57 +00:00
co-authored by Cursor
parent be8f76cd47
commit 799c302e2a
32 changed files with 601 additions and 89 deletions
+1 -1
View File
@@ -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))
@@ -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
+20 -4
View File
@@ -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);
@@ -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
@@ -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