Files
ladill-meet/app/Services/Meet/SessionService.php
T
isaaccladandCursor fa9b4138af
Deploy Ladill Meet / deploy (push) Successful in 1m3s
Implement conference breakouts with meeting mode and programme lineup UI.
Attendees reconnect to breakout rooms with publish access while hosts stay on stage; poll and media-token endpoints drive LiveKit reconnection, and the live room shows Events-linked programme when available.

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

363 lines
12 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 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, 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;
$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' => $this->canParticipantPublish($participant, $inBreakout),
'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]);
}
}
}