Files
ladill-meet/app/Services/Meet/SessionService.php
T
isaaccladandCursor d648f6c862
Deploy Ladill Meet / deploy (push) Successful in 33s
Add speak access for webinar/conference attendees and fix Events linking UX.
Let attendees request host-granted microphone access with LiveKit reconnect,
participant panel controls, and toolbar UI that only shows mic when allowed.
Fix Events create URL, surface API key setup guidance, and align the Webinars
sidebar icon with other nav items using currentColor strokes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 07:59:18 +00:00

433 lines
14 KiB
PHP

<?php
namespace App\Services\Meet;
use App\Models\Participant;
use App\Models\Room;
use App\Models\Session;
use App\Models\User;
use App\Services\Meet\Media\MediaProviderInterface;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class SessionService
{
public function __construct(
protected MediaProviderInterface $media,
protected RoomService $rooms,
) {}
public function start(Room $room, User $host): Session
{
$existing = $room->activeSession();
if ($existing) {
return $existing;
}
app(LicenseService::class)->assertCanStartSession($room->organization, $room->branch_id, $room);
$session = Session::create([
'owner_ref' => $room->owner_ref,
'room_id' => $room->id,
'media_room_name' => $this->rooms->mediaRoomName($room),
'status' => 'live',
'started_at' => now(),
]);
$room->update(['status' => 'live']);
$this->media->createRoom($session->media_room_name);
$this->joinParticipant($session, $host, 'host');
if ($room->setting('auto_record', false)) {
app(RecordingService::class)->start($session, $host);
}
app(MeetNotificationService::class)->meetingStarted($session, $host);
app(\App\Services\Integrations\WebhookDispatcher::class)->meetingStarted($session);
AuditLogger::record($room->owner_ref, 'session.started', $room->organization_id, $host->ownerRef(), Session::class, $session->id);
return $session;
}
public function end(Session $session, string $actorRef): Session
{
$session->update([
'status' => 'ended',
'ended_at' => now(),
]);
$session->room->update(['status' => 'ended']);
$session->participants()
->whereIn('status', ['joined', 'waiting'])
->update(['status' => 'left', 'left_at' => now()]);
foreach ($session->recordings()->where('status', 'recording')->get() as $recording) {
app(RecordingService::class)->stop($recording, $actorRef);
}
if ($session->room->isRecurring()) {
app(RecurringMeetingService::class)->spawnNextOccurrences($session->room, 1);
}
AuditLogger::record($session->owner_ref, 'session.ended', $session->room->organization_id, $actorRef, Session::class, $session->id);
app(\App\Services\Integrations\WebhookDispatcher::class)->meetingEnded($session);
app(UsageMeteringService::class)->recordSessionUsage($session);
return $session;
}
public function joinParticipant(
Session $session,
?User $user,
string $role,
?string $displayName = null,
?string $email = null,
string $status = 'joined',
): Participant {
$userRef = $user?->ownerRef();
$existing = $this->findExistingParticipant($session, $userRef, $email);
if ($existing) {
$updates = [
'left_at' => null,
'display_name' => $displayName ?? $existing->display_name,
'ip_address' => request()?->ip(),
];
if ($existing->status === 'waiting') {
$updates['status'] = 'waiting';
} else {
$updates['status'] = 'joined';
$updates['joined_at'] = $existing->joined_at ?? now();
}
if (in_array($role, ['host', 'co_host'], true)) {
$updates['role'] = $role;
} elseif ($existing->role === 'guest' && $role !== 'guest') {
$updates['role'] = $role;
}
$existing->update($updates);
return $existing;
}
$joinedAt = $status === 'joined' ? now() : null;
$participant = Participant::create([
'owner_ref' => $session->owner_ref,
'session_id' => $session->id,
'user_ref' => $userRef,
'display_name' => $displayName ?? $user?->name ?? 'Guest',
'email' => $email,
'role' => $role,
'status' => $status,
'joined_at' => $joinedAt,
'ip_address' => request()?->ip(),
]);
if ($status === 'joined') {
$this->updatePeakParticipants($session);
}
if ($status === 'joined') {
AuditLogger::record(
$session->owner_ref,
'participant.joined',
$session->room->organization_id,
$userRef,
Participant::class,
$participant->id,
);
}
return $participant;
}
public function admitParticipant(Participant $participant): Participant
{
abort_unless($participant->status === 'waiting', 422);
$participant->update([
'status' => 'joined',
'joined_at' => now(),
'left_at' => null,
]);
$this->updatePeakParticipants($participant->session);
AuditLogger::record(
$participant->owner_ref,
'participant.joined',
$participant->session->room->organization_id,
$participant->user_ref,
Participant::class,
$participant->id,
);
return $participant->fresh();
}
public function denyParticipant(Participant $participant): Participant
{
abort_unless($participant->status === 'waiting', 422);
$participant->update([
'status' => 'removed',
'left_at' => now(),
]);
AuditLogger::record(
$participant->owner_ref,
'participant.removed',
$participant->session->room->organization_id,
$participant->user_ref,
Participant::class,
$participant->id,
);
return $participant->fresh();
}
protected function findExistingParticipant(Session $session, ?string $userRef, ?string $email): ?Participant
{
$active = fn ($q) => $q->whereIn('status', ['invited', 'waiting', 'joined']);
if ($userRef) {
return $session->participants()
->where('user_ref', $userRef)
->where($active)
->first();
}
if ($email) {
return $session->participants()
->whereNull('user_ref')
->where('email', $email)
->where($active)
->first();
}
$guestUuid = (string) request()?->session()?->get("meet.participant.{$session->uuid}", '');
if ($guestUuid === '') {
return null;
}
return $session->participants()
->where('uuid', $guestUuid)
->whereNull('user_ref')
->where($active)
->first();
}
public function rememberParticipantSession(Request $request, Participant $participant, ?Session $session = null): void
{
$session ??= $participant->session;
$request->session()->put("meet.participant.{$session->uuid}", $participant->uuid);
}
public function ensureJoinedParticipant(
Request $request,
Session $session,
?User $user,
string $role,
?string $displayName = null,
?string $email = null,
): Participant {
if ($user) {
$existing = $this->joinedParticipantForUser($session, $user);
if ($existing) {
$this->rememberParticipantSession($request, $existing, $session);
return $existing;
}
}
$participant = $this->joinParticipant($session, $user, $role, $displayName, $email);
$this->rememberParticipantSession($request, $participant, $session);
return $participant;
}
public function transitionGreenRoomParticipant(
Request $request,
Participant $greenParticipant,
Session $liveSession,
): ?Participant {
$presenters = (array) $liveSession->presenter_refs;
$isPresenter = in_array($greenParticipant->role, ['host', 'co_host', 'panelist'], true)
|| ($greenParticipant->user_ref && in_array($greenParticipant->user_ref, $presenters, true));
if (! $isPresenter) {
return null;
}
$user = $greenParticipant->user_ref
? User::where('public_id', $greenParticipant->user_ref)->first()
: null;
$role = $greenParticipant->role;
if ($user && $user->ownerRef() === $liveSession->room->host_user_ref) {
$role = 'host';
} elseif (! in_array($role, ['host', 'co_host', 'panelist'], true)) {
$role = 'panelist';
}
$greenSession = $greenParticipant->session;
$participant = $this->ensureJoinedParticipant(
$request,
$liveSession,
$user,
$role,
$greenParticipant->display_name,
$greenParticipant->email,
);
$request->session()->forget("meet.participant.{$greenSession->uuid}");
return $participant;
}
public function joinedParticipantForUser(Session $session, User $user): ?Participant
{
return $session->participants()
->where('user_ref', $user->ownerRef())
->whereIn('status', ['invited', 'waiting', 'joined'])
->first();
}
public function leaveParticipant(Participant $participant): Participant
{
$participant->update([
'status' => 'left',
'left_at' => now(),
'hand_raised' => false,
]);
AuditLogger::record(
$participant->owner_ref,
'participant.left',
$participant->session->room->organization_id,
$participant->user_ref,
Participant::class,
$participant->id,
);
return $participant;
}
public function generateMediaToken(Participant $participant): string
{
$participant->loadMissing('breakoutRoom');
$state = $this->mediaSessionState($participant);
$mediaRoom = app(BreakoutService::class)->mediaRoomForParticipant($participant);
return $this->media->generateToken(
$mediaRoom,
$participant->uuid,
$participant->display_name,
[
'can_publish' => $state['can_publish'],
'can_subscribe' => true,
'can_publish_data' => true,
'room_admin' => $participant->isHost(),
],
);
}
/** @return array{can_publish: bool, audio_only_publish: bool, speak_granted: bool, speak_requested: bool, uses_stage_layout: bool, in_breakout: bool, breakout_room_id: ?int, breakout: ?array{uuid: string, name: string, closes_at: ?string}} */
public function mediaSessionState(Participant $participant): array
{
$participant->loadMissing(['breakoutRoom', 'session.room']);
$room = $participant->session->room;
$breakoutService = app(BreakoutService::class);
$inBreakout = $breakoutService->isInActiveBreakout($participant);
$usesStageLayout = app(StageLayoutService::class)->usesStageLayout($room) && ! $inBreakout;
$canPublish = $this->canParticipantPublish($participant, $inBreakout);
$audioOnlyPublish = $canPublish
&& $participant->isAttendee()
&& (bool) $participant->speak_granted
&& ! $inBreakout;
$breakout = null;
if ($inBreakout && $participant->breakoutRoom) {
$breakout = [
'uuid' => $participant->breakoutRoom->uuid,
'name' => $participant->breakoutRoom->name,
'closes_at' => $participant->breakoutRoom->closes_at?->toIso8601String(),
];
}
return [
'can_publish' => $canPublish,
'audio_only_publish' => $audioOnlyPublish,
'speak_granted' => (bool) $participant->speak_granted,
'speak_requested' => (bool) $participant->speak_requested,
'uses_stage_layout' => $usesStageLayout,
'in_breakout' => $inBreakout,
'breakout_room_id' => $inBreakout ? $participant->breakout_room_id : null,
'breakout' => $breakout,
];
}
public function canParticipantPublish(Participant $participant, ?bool $inBreakout = null): bool
{
$breakoutService = app(BreakoutService::class);
$inBreakout ??= $breakoutService->isInActiveBreakout($participant);
if ($inBreakout) {
return true;
}
$room = $participant->session->room;
if ($room->isSpace()) {
return app(\App\Services\Meet\SpaceService::class)->canPublish($room, $participant)
&& $participant->canPublishMedia();
}
return app(WebinarService::class)->canPublish($room, $participant)
&& $participant->canPublishMedia();
}
public function getOrStartSession(Room $room, User $host): Session
{
if ($room->activeSession()) {
return $room->activeSession();
}
return $this->start($room, $host);
}
public function lock(Session $session, string $actorRef): Session
{
$session->update(['is_locked' => true, 'locked_at' => now()]);
AuditLogger::record($session->owner_ref, 'meeting.locked', $session->room->organization_id, $actorRef, Session::class, $session->id);
return $session;
}
public function unlock(Session $session, string $actorRef): Session
{
$session->update(['is_locked' => false, 'locked_at' => null]);
AuditLogger::record($session->owner_ref, 'meeting.unlocked', $session->room->organization_id, $actorRef, Session::class, $session->id);
return $session;
}
protected function updatePeakParticipants(Session $session): void
{
$count = $session->participants()->where('status', 'joined')->count();
if ($count > $session->peak_participants) {
$session->update(['peak_participants' => $count]);
}
}
}