diff --git a/app/Http/Controllers/Meet/JoinController.php b/app/Http/Controllers/Meet/JoinController.php index f921a2e..cb9e7a7 100644 --- a/app/Http/Controllers/Meet/JoinController.php +++ b/app/Http/Controllers/Meet/JoinController.php @@ -3,11 +3,14 @@ namespace App\Http\Controllers\Meet; use App\Http\Controllers\Controller; +use App\Models\Participant; use App\Models\Room; +use App\Models\Session; use App\Services\Meet\InvitationService; use App\Services\Meet\SecurityPolicyService; use App\Services\Meet\SessionService; use App\Services\Meet\WebinarService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; @@ -23,8 +26,13 @@ class JoinController extends Controller public function show(Request $request, Room $room): View|RedirectResponse { + $room->loadMissing('organization'); + if ($room->status === 'cancelled') { - return view('meet.join.cancelled', compact('room')); + return view('meet.join.cancelled', [ + 'room' => $room, + 'organization' => $room->organization, + ]); } if ($room->isWebinar() && $request->query('token')) { @@ -33,15 +41,39 @@ class JoinController extends Controller [$allowed, $reason] = $this->security->canJoin($room, $request->user(), $request->user()?->email); if (! $allowed && $reason !== 'passcode_required') { - return view('meet.join.denied', compact('room', 'reason')); + return view('meet.join.denied', [ + 'room' => $room, + 'organization' => $room->organization, + 'reason' => $reason, + ]); } if ($room->passcode && ! $request->session()->get("meet.passcode.{$room->uuid}")) { - return view('meet.join.passcode', compact('room')); + return view('meet.join.passcode', [ + 'room' => $room, + 'organization' => $room->organization, + ]); + } + + $session = $room->activeSession(); + if ($session) { + $participantUuid = $request->session()->get("meet.participant.{$session->uuid}"); + $participant = $participantUuid + ? Participant::where('uuid', $participantUuid)->where('session_id', $session->id)->first() + : null; + + if ($participant?->status === 'waiting') { + return redirect()->route('meet.join.waiting', $room); + } + + if ($participant?->status === 'joined') { + return redirect()->route('meet.room', $session); + } } return view('meet.join.show', [ 'room' => $room, + 'organization' => $room->organization, 'user' => $request->user(), 'isWebinar' => $room->isWebinar(), ]); @@ -130,12 +162,72 @@ class JoinController extends Controller $displayName = $reg->display_name; } - $participant = $this->sessions->joinParticipant($session, $user, $role, $displayName, $email); + $joinStatus = $room->requiresWaitingRoom($user, $role) ? 'waiting' : 'joined'; + + $participant = $this->sessions->joinParticipant($session, $user, $role, $displayName, $email, $joinStatus); app(InvitationService::class)->markAcceptedOnJoin($room, $user, $email); $this->sessions->rememberParticipantSession($request, $participant, $session); + if ($participant->status === 'waiting') { + return redirect()->route('meet.join.waiting', $room); + } + return redirect()->route('meet.room', $session); } + + public function waiting(Request $request, Room $room): View|RedirectResponse + { + $room->loadMissing('organization'); + + $session = $room->activeSession(); + abort_unless($session?->isLive(), 404); + + $participant = $this->participantFromSession($request, $session); + + if (! $participant || $participant->status === 'removed') { + return redirect()->route('meet.join', $room); + } + + if ($participant->status === 'joined') { + return redirect()->route('meet.room', $session); + } + + return view('meet.join.waiting', [ + 'room' => $room, + 'session' => $session, + 'organization' => $room->organization, + 'participant' => $participant, + ]); + } + + public function waitingStatus(Request $request, Room $room): JsonResponse + { + $session = $room->activeSession(); + abort_unless($session, 404); + + $participant = $this->participantFromSession($request, $session); + + return response()->json([ + 'status' => $participant?->status ?? 'unknown', + 'redirect' => ($participant && $participant->status === 'joined') + ? route('meet.room', $session) + : null, + 'denied' => $participant?->status === 'removed', + ]); + } + + protected function participantFromSession(Request $request, Session $session): ?Participant + { + $participantUuid = $request->session()->get("meet.participant.{$session->uuid}"); + if (! $participantUuid) { + return null; + } + + return Participant::query() + ->where('uuid', $participantUuid) + ->where('session_id', $session->id) + ->first(); + } } diff --git a/app/Http/Controllers/Meet/LeaveController.php b/app/Http/Controllers/Meet/LeaveController.php new file mode 100644 index 0000000..d1f6e7f --- /dev/null +++ b/app/Http/Controllers/Meet/LeaveController.php @@ -0,0 +1,80 @@ +loadMissing('room.organization'); + abort_unless($session->room, 404); + + $feedback = (array) $request->session()->get('meet.left.feedback', []); + + if (($feedback['session_uuid'] ?? null) !== $session->uuid) { + return redirect()->route('meet.join', $session->room); + } + + return view('meet.left.show', [ + 'session' => $session, + 'room' => $session->room, + 'organization' => $session->room->organization, + 'feedback' => $feedback, + ]); + } + + public function store(Request $request, Session $session): RedirectResponse + { + $feedback = (array) $request->session()->get('meet.left.feedback', []); + + abort_unless(($feedback['session_uuid'] ?? null) === $session->uuid, 403); + + $validated = $request->validate([ + 'rating' => ['required', 'integer', 'min:1', 'max:5'], + 'audio_rating' => ['nullable', 'integer', 'min:1', 'max:5'], + 'video_rating' => ['nullable', 'integer', 'min:1', 'max:5'], + 'comment' => ['nullable', 'string', 'max:1000'], + ]); + + SessionFeedback::updateOrCreate( + [ + 'session_id' => $session->id, + 'participant_uuid' => $feedback['participant_uuid'] ?? null, + ], + [ + 'display_name' => $feedback['display_name'] ?? null, + 'user_ref' => $feedback['user_ref'] ?? null, + 'rating' => $validated['rating'], + 'audio_rating' => $validated['audio_rating'] ?? null, + 'video_rating' => $validated['video_rating'] ?? null, + 'comment' => $validated['comment'] ?? null, + ], + ); + + $request->session()->forget('meet.left.feedback'); + + return redirect() + ->route('meet.left.thanks', $session) + ->with('rated', true); + } + + public function thanks(Request $request, Session $session): View + { + $session->loadMissing('room.organization'); + + return view('meet.left.thanks', [ + 'session' => $session, + 'room' => $session->room, + 'organization' => $session->room->organization, + ]); + } +} diff --git a/app/Http/Controllers/Meet/MeetingRoomController.php b/app/Http/Controllers/Meet/MeetingRoomController.php index 019491b..57f5e36 100644 --- a/app/Http/Controllers/Meet/MeetingRoomController.php +++ b/app/Http/Controllers/Meet/MeetingRoomController.php @@ -39,6 +39,12 @@ class MeetingRoomController extends Controller ->where('session_id', $session->id) ->firstOrFail(); + if ($participant->status === 'waiting') { + return redirect()->route('meet.join.waiting', $session->room); + } + + abort_unless($participant->status === 'joined', 403, 'You are not in this meeting.'); + $token = $this->media->isConfigured() ? $this->sessions->generateMediaToken($participant) : ''; @@ -81,11 +87,18 @@ class MeetingRoomController extends Controller if ($participant) { $this->sessions->leaveParticipant($participant); + + $request->session()->put('meet.left.feedback', [ + 'session_uuid' => $session->uuid, + 'participant_uuid' => $participant->uuid, + 'display_name' => $participant->display_name, + 'user_ref' => $participant->user_ref, + ]); } $request->session()->forget("meet.participant.{$session->uuid}"); - return redirect()->route('meet.dashboard'); + return redirect()->route('meet.left', $session); } public function raiseHand(Request $request, Session $session): JsonResponse @@ -145,7 +158,7 @@ class MeetingRoomController extends Controller { $this->currentParticipant($request, $session); - $session->load(['participants' => fn ($q) => $q->where('status', 'joined')]); + $session->load(['participants' => fn ($q) => $q->whereIn('status', ['joined', 'waiting'])]); $avatars = ParticipantPresenter::avatarMap($session->participants, $session->room->host_user_ref); @@ -167,6 +180,7 @@ class MeetingRoomController extends Controller 'participants' => $session->participants->map( fn ($p) => ParticipantPresenter::toArray($p, $avatars, $session->room->host_user_ref) ), + 'waiting_count' => $session->participants->where('status', 'waiting')->count(), 'messages' => $messages->map(fn ($m) => [ 'uuid' => $m->uuid, 'sender_name' => $m->sender_name, @@ -271,6 +285,28 @@ class MeetingRoomController extends Controller return response()->json(['ok' => true]); } + public function admit(Request $request, Session $session, Participant $participant): JsonResponse + { + $current = $this->currentParticipant($request, $session); + abort_unless($current->isHost(), 403); + abort_unless($participant->session_id === $session->id, 404); + + $this->sessions->admitParticipant($participant); + + return response()->json(['ok' => true, 'status' => 'joined']); + } + + public function deny(Request $request, Session $session, Participant $participant): JsonResponse + { + $current = $this->currentParticipant($request, $session); + abort_unless($current->isHost(), 403); + abort_unless($participant->session_id === $session->id, 404); + + $this->sessions->denyParticipant($participant); + + return response()->json(['ok' => true, 'status' => 'removed']); + } + protected function currentParticipant(Request $request, Session $session): Participant { $participantUuid = $request->session()->get("meet.participant.{$session->uuid}"); diff --git a/app/Models/Room.php b/app/Models/Room.php index b6f0048..5fd4a1a 100644 --- a/app/Models/Room.php +++ b/app/Models/Room.php @@ -111,6 +111,19 @@ class Room extends Model return $this->status === 'live'; } + public function requiresWaitingRoom(?User $user, string $role): bool + { + if (! $this->setting('waiting_room', true)) { + return false; + } + + if ($role === 'host' || ($user && $user->ownerRef() === $this->host_user_ref)) { + return false; + } + + return true; + } + public function joinUrl(): string { return rtrim((string) config('app.meet_url', 'https://meet.ladill.com'), '/').'/r/'.$this->uuid; diff --git a/app/Models/SessionFeedback.php b/app/Models/SessionFeedback.php new file mode 100644 index 0000000..9d64ed5 --- /dev/null +++ b/app/Models/SessionFeedback.php @@ -0,0 +1,27 @@ +belongsTo(Session::class, 'session_id'); + } +} diff --git a/app/Services/Meet/ParticipantPresenter.php b/app/Services/Meet/ParticipantPresenter.php index a83c678..b51ee19 100644 --- a/app/Services/Meet/ParticipantPresenter.php +++ b/app/Services/Meet/ParticipantPresenter.php @@ -38,6 +38,7 @@ class ParticipantPresenter 'uuid' => $participant->uuid, 'display_name' => $participant->display_name, 'role' => $participant->role, + 'status' => $participant->status, 'hand_raised' => $participant->hand_raised, 'is_muted' => $participant->is_muted, 'is_video_off' => $participant->is_video_off, diff --git a/app/Services/Meet/SessionService.php b/app/Services/Meet/SessionService.php index f5f8220..dec07c4 100644 --- a/app/Services/Meet/SessionService.php +++ b/app/Services/Meet/SessionService.php @@ -63,7 +63,7 @@ class SessionService $session->room->update(['status' => 'ended']); $session->participants() - ->where('status', 'joined') + ->whereIn('status', ['joined', 'waiting']) ->update(['status' => 'left', 'left_at' => now()]); foreach ($session->recordings()->where('status', 'recording')->get() as $recording) { @@ -89,22 +89,32 @@ class SessionService string $role, ?string $displayName = null, ?string $email = null, + string $status = 'joined', ): Participant { $userRef = $user?->ownerRef(); $existing = $this->findExistingParticipant($session, $userRef, $email); if ($existing) { - $existing->update([ - 'status' => 'joined', - 'joined_at' => now(), + $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(); + } + + $existing->update($updates); return $existing; } + $joinedAt = $status === 'joined' ? now() : null; + $participant = Participant::create([ 'owner_ref' => $session->owner_ref, 'session_id' => $session->id, @@ -112,23 +122,72 @@ class SessionService 'display_name' => $displayName ?? $user?->name ?? 'Guest', 'email' => $email, 'role' => $role, - 'status' => 'joined', - 'joined_at' => now(), + 'status' => $status, + 'joined_at' => $joinedAt, 'ip_address' => request()?->ip(), ]); - $this->updatePeakParticipants($session); + 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( - $session->owner_ref, + $participant->owner_ref, 'participant.joined', - $session->room->organization_id, - $userRef, + $participant->session->room->organization_id, + $participant->user_ref, Participant::class, $participant->id, ); - return $participant; + 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 diff --git a/bootstrap/app.php b/bootstrap/app.php index d8ed3c1..e3d552c 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -51,6 +51,9 @@ return Application::configure(basePath: dirname(__DIR__)) \Illuminate\Support\Facades\Route::bind('registration', function (string $value) { return \App\Models\WebinarRegistration::where('uuid', $value)->firstOrFail(); }); + \Illuminate\Support\Facades\Route::bind('participant', function (string $value) { + return \App\Models\Participant::where('uuid', $value)->firstOrFail(); + }); }, ) ->withMiddleware(function (Middleware $middleware): void { diff --git a/database/migrations/2026_07_01_120000_create_meet_session_feedback_table.php b/database/migrations/2026_07_01_120000_create_meet_session_feedback_table.php new file mode 100644 index 0000000..9ed4698 --- /dev/null +++ b/database/migrations/2026_07_01_120000_create_meet_session_feedback_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('session_id')->constrained('meet_sessions')->cascadeOnDelete(); + $table->uuid('participant_uuid')->nullable()->index(); + $table->string('display_name')->nullable(); + $table->string('user_ref')->nullable()->index(); + $table->unsignedTinyInteger('rating'); + $table->unsignedTinyInteger('audio_rating')->nullable(); + $table->unsignedTinyInteger('video_rating')->nullable(); + $table->text('comment')->nullable(); + $table->timestamps(); + + $table->unique(['session_id', 'participant_uuid']); + }); + } + + public function down(): void + { + Schema::dropIfExists('meet_session_feedback'); + } +}; diff --git a/resources/js/meet-public.js b/resources/js/meet-public.js new file mode 100644 index 0000000..5fe419c --- /dev/null +++ b/resources/js/meet-public.js @@ -0,0 +1,70 @@ +import Alpine from 'alpinejs'; + +Alpine.data('waitingRoom', () => ({ + message: 'Waiting for the host to let you in…', + denied: false, + pollTimer: null, + + init() { + this.pollTimer = setInterval(() => this.poll(), 2500); + this.poll(); + }, + + destroy() { + if (this.pollTimer) { + clearInterval(this.pollTimer); + } + }, + + async poll() { + const url = this.$el.dataset.statusUrl; + if (!url) return; + + try { + const res = await fetch(url, { headers: { Accept: 'application/json' } }); + const data = await res.json(); + + if (data.redirect) { + window.location.href = data.redirect; + return; + } + + if (data.denied) { + this.denied = true; + this.message = 'The host declined your request to join.'; + clearInterval(this.pollTimer); + } + } catch { + // ignore transient errors + } + }, +})); + +Alpine.data('leaveFeedback', () => ({ + rating: 0, + audioRating: 0, + videoRating: 0, + hoverRating: 0, + hoverAudio: 0, + hoverVideo: 0, + comment: '', + + setRating(value) { + this.rating = value; + }, + + setAudioRating(value) { + this.audioRating = value; + }, + + setVideoRating(value) { + this.videoRating = value; + }, + + canSubmit() { + return this.rating > 0; + }, +})); + +window.Alpine = Alpine; +Alpine.start(); diff --git a/resources/js/meet-room.js b/resources/js/meet-room.js index fdf1d8d..400944f 100644 --- a/resources/js/meet-room.js +++ b/resources/js/meet-room.js @@ -85,6 +85,7 @@ function meetRoom() { whiteboardUrl: el.dataset.whiteboardUrl, whiteboardSaveUrl: el.dataset.whiteboardSaveUrl, csrf: el.dataset.csrf, + sessionUuid: el.dataset.sessionUuid, }; this.joinUrl = el.dataset.joinUrl || ''; @@ -334,7 +335,7 @@ function meetRoom() { }, hostParticipant() { - const hosts = this.sessionParticipants.filter((p) => p.role === 'host' || p.role === 'co_host'); + const hosts = this.inCallParticipants().filter((p) => p.role === 'host' || p.role === 'co_host'); if (hosts.length) { return hosts[0]; } @@ -352,13 +353,21 @@ function meetRoom() { }, participantCount() { - return Math.max(this.sessionParticipants.length, this.participants.length); + return this.inCallParticipants().length; + }, + + inCallParticipants() { + return this.sessionParticipants.filter((p) => p.status === 'joined'); + }, + + waitingParticipants() { + return this.sessionParticipants.filter((p) => p.status === 'waiting'); }, sortedParticipants() { const order = { host: 0, co_host: 1, panelist: 2, participant: 3, guest: 4, attendee: 5 }; - return [...this.sessionParticipants].sort((a, b) => { + return [...this.inCallParticipants()].sort((a, b) => { const diff = (order[a.role] ?? 9) - (order[b.role] ?? 9); return diff !== 0 ? diff : a.display_name.localeCompare(b.display_name); @@ -378,6 +387,26 @@ function meetRoom() { return labels[role] || 'Participant'; }, + async admitParticipant(uuid) { + if (!this.isHost || !this.config.sessionUuid) return; + + await fetch(`/room/${this.config.sessionUuid}/participants/${uuid}/admit`, { + method: 'POST', + headers: this.headers(), + }); + await this.poll(); + }, + + async denyParticipant(uuid) { + if (!this.isHost || !this.config.sessionUuid) return; + + await fetch(`/room/${this.config.sessionUuid}/participants/${uuid}/deny`, { + method: 'POST', + headers: this.headers(), + }); + await this.poll(); + }, + getOrCreateParticipantTile(participant) { const grid = document.getElementById('video-grid'); if (!grid) return null; diff --git a/resources/views/components/meet-kiosk-layout.blade.php b/resources/views/components/meet-kiosk-layout.blade.php new file mode 100644 index 0000000..b476195 --- /dev/null +++ b/resources/views/components/meet-kiosk-layout.blade.php @@ -0,0 +1,45 @@ +@props([ + 'title' => 'Ladill Meet', + 'organization' => null, + 'eyebrow' => null, +]) + + + +
+ + + +{{ $eyebrow }}
+ @endif + {{ $slot }} +{{ $room->title }} is no longer available.
+This meeting has been cancelled by the host.
+{{ $reason ?? 'You do not have access to this meeting.' }}
- Sign in +{{ $reason ?? 'You do not have access to this meeting.' }}
+ + Sign in with Ladill + +Enter the meeting passcode to continue.
+This meeting is protected. Enter the passcode to continue.
- + @if ($errors->any()) +Ready to join?
++ {{ $room->scheduled_at->timezone($room->timezone)->format('l, j F Y · g:i A T') }} +
+ @endif -Hi {{ $participant->display_name }}, please wait.
+ + +You will join automatically once the host admits you.
+ + + Back to join page + +{{ $room->title }}
+ @if (! empty($feedback['display_name'])) +Thanks, {{ $feedback['display_name'] }}.
+ @endif +How was the meeting overall?
+Audio quality
+Video quality
+{{ $room->title }}
+ +Waiting to join
+ +No participants yet.
+No participants yet.