diff --git a/app/Http/Controllers/Meet/Concerns/BuildsMeetIndexPage.php b/app/Http/Controllers/Meet/Concerns/BuildsMeetIndexPage.php new file mode 100644 index 0000000..fd69079 --- /dev/null +++ b/app/Http/Controllers/Meet/Concerns/BuildsMeetIndexPage.php @@ -0,0 +1,74 @@ + + */ + protected function meetIndexStats(Request $request, Builder $roomQuery, string $countLabel, string $secondaryLabel): array + { + $owner = $this->ownerRef($request); + $organization = $this->organization($request); + + $balance = '—'; + try { + $minor = app(BillingClient::class)->balanceMinor($owner); + $currency = (string) config('billing.currency', 'GHS'); + $balance = $currency.' '.number_format($minor / 100, 2); + } catch (\Throwable) { + // Wallet API unavailable — show placeholder. + } + + $total = (clone $roomQuery)->count(); + $live = (clone $roomQuery)->where('status', 'live')->count(); + + return [ + ['value' => $balance, 'label' => 'Account balance'], + ['value' => (string) $total, 'label' => $countLabel], + ['value' => (string) $live, 'label' => $secondaryLabel], + ]; + } + + /** + * @return array + */ + protected function meetIndexItems(iterable $rooms, callable $detailsUrl): array + { + $items = []; + + foreach ($rooms as $room) { + /** @var Room $room */ + $typeLabel = match (true) { + $room->isSpace() => 'Room', + $room->isWebinar() => 'Webinar', + $room->isConference() => 'Conference', + default => ucfirst(config('meet.room_types')[$room->type] ?? $room->type), + }; + + $schedule = $room->scheduled_at + ? $room->scheduled_at->timezone($room->timezone)->format('M j, Y g:i A') + : ($room->isConference() ? 'Instant conference' : ($room->isSpace() ? 'Audio room' : 'Instant meeting')); + + $statusLabel = config('meet.room_statuses')[$room->status] ?? ucfirst($room->status); + + $items[] = [ + 'title' => $room->title, + 'subtitle' => $typeLabel.' · '.$room->joinUrl(), + 'meta' => $schedule, + 'status' => $statusLabel, + 'url' => $detailsUrl($room), + 'active' => $room->status !== 'cancelled' && $room->status !== 'ended', + ]; + } + + return $items; + } + +} diff --git a/app/Http/Controllers/Meet/ConferenceController.php b/app/Http/Controllers/Meet/ConferenceController.php index 710f895..ec8ccb5 100644 --- a/app/Http/Controllers/Meet/ConferenceController.php +++ b/app/Http/Controllers/Meet/ConferenceController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Meet; use App\Http\Controllers\Controller; +use App\Http\Controllers\Meet\Concerns\BuildsMeetIndexPage; use App\Http\Controllers\Meet\Concerns\ScopesToAccount; use App\Models\Member; use App\Models\Room; @@ -19,7 +20,7 @@ use Illuminate\View\View; class ConferenceController extends Controller { - use ScopesToAccount; + use BuildsMeetIndexPage, ScopesToAccount; public function __construct( protected RoomService $rooms, @@ -44,7 +45,10 @@ class ConferenceController extends Controller $conferences = $query->orderByDesc('scheduled_at')->paginate(20); - return view('meet.conferences.index', compact('conferences', 'organization')); + $stats = $this->meetIndexStats($request, $query, 'Conferences', 'Live now'); + $items = $this->meetIndexItems($conferences, fn (Room $room) => route('meet.conferences.show', $room)); + + return view('meet.conferences.index', compact('conferences', 'organization', 'stats', 'items')); } public function create(Request $request): View diff --git a/app/Http/Controllers/Meet/RoomController.php b/app/Http/Controllers/Meet/RoomController.php index 090d789..121d9d2 100644 --- a/app/Http/Controllers/Meet/RoomController.php +++ b/app/Http/Controllers/Meet/RoomController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Meet; use App\Http\Controllers\Controller; +use App\Http\Controllers\Meet\Concerns\BuildsMeetIndexPage; use App\Http\Controllers\Meet\Concerns\ScopesToAccount; use App\Models\Room; use App\Services\Meet\AttendanceService; @@ -22,7 +23,7 @@ use Illuminate\View\View; class RoomController extends Controller { - use ScopesToAccount; + use BuildsMeetIndexPage, ScopesToAccount; public function __construct( protected RoomService $rooms, @@ -47,7 +48,10 @@ class RoomController extends Controller $rooms = $query->orderByDesc('scheduled_at')->paginate(20); - return view('meet.rooms.index', compact('rooms', 'organization')); + $stats = $this->meetIndexStats($request, $query, 'Meetings', 'Live now'); + $items = $this->meetIndexItems($rooms, fn (Room $room) => route('meet.rooms.show', $room)); + + return view('meet.rooms.index', compact('rooms', 'organization', 'stats', 'items')); } public function create(Request $request): View diff --git a/app/Http/Controllers/Meet/SpaceController.php b/app/Http/Controllers/Meet/SpaceController.php index abc073a..0d60ebf 100644 --- a/app/Http/Controllers/Meet/SpaceController.php +++ b/app/Http/Controllers/Meet/SpaceController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Meet; use App\Http\Controllers\Controller; +use App\Http\Controllers\Meet\Concerns\BuildsMeetIndexPage; use App\Http\Controllers\Meet\Concerns\ScopesToAccount; use App\Models\Member; use App\Models\Room; @@ -17,7 +18,7 @@ use Illuminate\View\View; class SpaceController extends Controller { - use ScopesToAccount; + use BuildsMeetIndexPage, ScopesToAccount; public function __construct( protected RoomService $rooms, @@ -41,7 +42,10 @@ class SpaceController extends Controller $spaces = $query->orderByDesc('updated_at')->paginate(20); - return view('meet.spaces.index', compact('spaces', 'organization')); + $stats = $this->meetIndexStats($request, $query, 'Rooms', 'Live now'); + $items = $this->meetIndexItems($spaces, fn (Room $room) => route('meet.spaces.show', $room)); + + return view('meet.spaces.index', compact('spaces', 'organization', 'stats', 'items')); } public function create(Request $request): View @@ -76,7 +80,18 @@ class SpaceController extends Controller 'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'], 'timezone' => ['nullable', 'timezone'], 'passcode' => ['nullable', 'string', 'max:20'], - 'invite_emails' => ['nullable', 'string'], + 'invite_emails' => ['nullable', 'string', 'max:2000', function (string $attribute, mixed $value, \Closure $fail): void { + if (! is_string($value) || trim($value) === '') { + return; + } + + foreach (preg_split('/[\s,;]+/', $value) as $email) { + $email = trim($email); + if ($email !== '' && ! filter_var($email, FILTER_VALIDATE_EMAIL)) { + $fail("Invalid email address: {$email}"); + } + } + }], 'speaker_refs' => ['nullable', 'array'], 'speaker_refs.*' => ['string', 'max:64'], 'invite_only' => ['sometimes', 'boolean'], @@ -115,7 +130,7 @@ class SpaceController 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' => 'attendee']) ->all(); $this->invitations->inviteToRoom($room, $invites, $request->user()); } @@ -124,7 +139,18 @@ class SpaceController extends Controller $this->calendar->syncRoomCreated($room, $request->user()); } - return redirect()->route('meet.spaces.show', $room)->with('success', 'Room created.'); + return redirect()->route('meet.spaces.show', $room)->with('success', $this->spaceCreatedMessage($room, $validated['invite_emails'] ?? '')); + } + + protected function spaceCreatedMessage(Room $room, string $inviteEmails): string + { + $count = count(array_filter(preg_split('/[\s,;]+/', trim($inviteEmails)) ?: [])); + + if ($count === 0) { + return 'Room created.'; + } + + return "Room created. {$count} listener invitation(s) sent."; } public function show(Request $request, Room $room): View @@ -133,7 +159,7 @@ class SpaceController extends Controller $this->authorizeOwner($request, $room); abort_unless($room->isSpace(), 404); - $room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles']); + $room->load(['sessions.participants', 'invitations' => fn ($q) => $q->orderByDesc('created_at'), 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles']); $speakerRefs = (array) $room->setting('speaker_refs', []); $speakers = Member::owned($this->ownerRef($request)) diff --git a/app/Http/Controllers/Meet/WebinarController.php b/app/Http/Controllers/Meet/WebinarController.php index 3f62977..442bd89 100644 --- a/app/Http/Controllers/Meet/WebinarController.php +++ b/app/Http/Controllers/Meet/WebinarController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Meet; use App\Http\Controllers\Controller; +use App\Http\Controllers\Meet\Concerns\BuildsMeetIndexPage; use App\Http\Controllers\Meet\Concerns\ScopesToAccount; use App\Models\Room; use App\Services\Meet\CalendarService; @@ -15,7 +16,7 @@ use Illuminate\View\View; class WebinarController extends Controller { - use ScopesToAccount; + use BuildsMeetIndexPage, ScopesToAccount; public function __construct( protected RoomService $rooms, @@ -38,7 +39,10 @@ class WebinarController extends Controller $webinars = $query->orderByDesc('scheduled_at')->paginate(20); - return view('meet.webinars.index', compact('webinars', 'organization')); + $stats = $this->meetIndexStats($request, $query, 'Webinars', 'Live now'); + $items = $this->meetIndexItems($webinars, fn (Room $room) => route('meet.webinars.show', $room)); + + return view('meet.webinars.index', compact('webinars', 'organization', 'stats', 'items')); } public function create(Request $request): View diff --git a/app/Services/Meet/InvitationService.php b/app/Services/Meet/InvitationService.php index 1b01766..7771e3a 100644 --- a/app/Services/Meet/InvitationService.php +++ b/app/Services/Meet/InvitationService.php @@ -18,7 +18,9 @@ class InvitationService */ public function inviteToRoom(Room $room, array $invites, User $host): void { - $normalized = collect($invites)->map(function ($invite) { + $defaultRole = $room->isSpace() ? 'attendee' : 'participant'; + + $normalized = collect($invites)->map(function ($invite) use ($defaultRole) { $email = trim((string) ($invite['email'] ?? '')); if ($email === '') { return null; @@ -29,7 +31,7 @@ class InvitationService return [ 'email' => $email, 'name' => $invite['name'] ?? $invite['display_name'] ?? $user?->name, - 'role' => $invite['role'] ?? 'participant', + 'role' => $invite['role'] ?? $defaultRole, 'user_ref' => $invite['user_ref'] ?? $user?->ownerRef(), ]; })->filter()->unique('email')->values()->all(); diff --git a/app/Services/Meet/SecurityPolicyService.php b/app/Services/Meet/SecurityPolicyService.php index 1c34d5c..0c252ef 100644 --- a/app/Services/Meet/SecurityPolicyService.php +++ b/app/Services/Meet/SecurityPolicyService.php @@ -2,6 +2,7 @@ namespace App\Services\Meet; +use App\Models\Member; use App\Models\Organization; use App\Models\Room; use App\Models\User; @@ -13,7 +14,7 @@ class SecurityPolicyService { $org = $room->organization; $policy = $this->mergedPolicy($org, $room); - $kind = $room->isConference() ? 'conference' : 'meeting'; + $kind = $room->isConference() ? 'conference' : ($room->isSpace() ? 'room' : 'meeting'); if ($policy['org_only'] && ! $user) { return [false, "This {$kind} requires a Ladill account."]; @@ -31,6 +32,10 @@ class SecurityPolicyService } } + if ($room->isSpace() && $room->setting('invite_only', false) && ! $this->canJoinInviteOnlyRoom($room, $user, $email)) { + return [false, 'This room is private. Ask the host for an invitation.']; + } + if ($room->passcode && ! session()->get("meet.passcode.{$room->uuid}")) { return [false, 'passcode_required']; } @@ -46,6 +51,39 @@ class SecurityPolicyService return [true, null]; } + public function canJoinInviteOnlyRoom(Room $room, ?User $user, ?string $email): bool + { + if ($user && $this->isHost($room, $user)) { + return true; + } + + if ($user && $room->organization_id) { + $isMember = Member::query() + ->where('organization_id', $room->organization_id) + ->where('user_ref', $user->ownerRef()) + ->exists(); + + if ($isMember) { + return true; + } + + $speakerRefs = (array) $room->setting('speaker_refs', []); + if (in_array($user->ownerRef(), $speakerRefs, true)) { + return true; + } + } + + $email = strtolower(trim((string) ($email ?? $user?->email ?? ''))); + if ($email === '') { + return false; + } + + return $room->invitations() + ->whereRaw('LOWER(email) = ?', [$email]) + ->whereIn('status', ['pending', 'accepted']) + ->exists(); + } + public function isHost(Room $room, ?User $user): bool { return $user && $user->ownerRef() === $room->host_user_ref; diff --git a/resources/views/meet/conferences/index.blade.php b/resources/views/meet/conferences/index.blade.php index c62a3b1..c83ad28 100644 --- a/resources/views/meet/conferences/index.blade.php +++ b/resources/views/meet/conferences/index.blade.php @@ -1,22 +1,17 @@ -
-
-

Conferences

-

Multi-speaker events with hosts and presenters. Billed at GHS {{ number_format(config('meet.billing.price_per_participant_ghs', 0.30), 2) }} per participant.

-
- Schedule conference -
- -
- @forelse ($conferences as $room) - @include('meet.partials.room-card', [ - 'room' => $room, - 'detailsRoute' => route('meet.conferences.show', $room), - ]) - @empty -

No conferences yet.

- @endforelse -
+ @include('meet.partials.index-page', [ + 'badge' => 'Multi-speaker · Stage · Panels', + 'title' => 'Your conferences', + 'description' => 'Run multi-speaker events with hosts and presenters. Billed at GHS '.number_format(config('meet.billing.price_per_participant_ghs', 0.30), 2).' per participant.', + 'createUrl' => route('meet.conferences.create'), + 'createLabel' => 'Schedule conference', + 'stats' => $stats, + 'listTitle' => 'Your conferences', + 'emptyMessage' => 'No conferences yet. Schedule your first conference to get started.', + 'items' => $items, + 'icon' => 'meet-icons/panel.svg', + 'iconBg' => 'bg-violet-100', + ])
{{ $conferences->links() }}
diff --git a/resources/views/meet/invitations/index.blade.php b/resources/views/meet/invitations/index.blade.php index 9ae0a14..77d31a1 100644 --- a/resources/views/meet/invitations/index.blade.php +++ b/resources/views/meet/invitations/index.blade.php @@ -24,10 +24,10 @@
@csrf -

Invite people

+

{{ $room->isSpace() ? 'Invite listeners' : 'Invite people' }}

- +
@if ($groups->isNotEmpty())
diff --git a/resources/views/meet/join/denied.blade.php b/resources/views/meet/join/denied.blade.php index 9005157..9140295 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 '.($room->isConference() ? 'conference' : 'meeting').'.' }}

+

{{ $reason ?? 'You do not have access to this '.match (true) { $room->isSpace() => 'room', $room->isConference() => 'conference', $room->isWebinar() => 'webinar', default => 'meeting' } }}.' }}

Sign in with Ladill diff --git a/resources/views/meet/partials/index-page.blade.php b/resources/views/meet/partials/index-page.blade.php new file mode 100644 index 0000000..383c379 --- /dev/null +++ b/resources/views/meet/partials/index-page.blade.php @@ -0,0 +1,83 @@ +@props([ + 'badge', + 'title', + 'description', + 'createUrl', + 'createLabel', + 'stats' => [], + 'listTitle', + 'emptyMessage', + 'items' => [], + 'icon' => 'meet-icons/schedule.svg', + 'iconBg' => 'bg-violet-100', +]) + +
+ @foreach (['success', 'error'] as $flash) + @if (session($flash)) +
+

{{ session($flash) }}

+
+ @endif + @endforeach + +
+
+
+
+
+
+ {{ $badge }} +
+

{{ $title }}

+

{{ $description }}

+ +
+ + @if (count($stats) > 0) +
+ @foreach ($stats as $stat) +
+

{{ $stat['value'] }}

+

{{ $stat['label'] }}

+
+ @endforeach +
+ @endif +
+
+
+ +
+
+

{{ $listTitle }}

+
+ @if (count($items) === 0) +

{{ $emptyMessage }}

+ @else + + @endif +
+
diff --git a/resources/views/meet/rooms/index.blade.php b/resources/views/meet/rooms/index.blade.php index 0df543e..7742607 100644 --- a/resources/views/meet/rooms/index.blade.php +++ b/resources/views/meet/rooms/index.blade.php @@ -1,22 +1,17 @@ - - -
- @forelse ($rooms as $room) - @include('meet.partials.room-card', ['room' => $room]) - @empty -

No meetings yet.

- @endforelse -
+ @include('meet.partials.index-page', [ + 'badge' => 'Video · Screen share · Chat', + 'title' => 'Your meetings', + 'description' => 'Schedule or start instant video meetings, share your screen, and collaborate with your team.', + 'createUrl' => route('meet.rooms.create'), + 'createLabel' => 'Schedule meeting', + 'stats' => $stats, + 'listTitle' => 'Your meetings', + 'emptyMessage' => 'No meetings yet. Schedule your first meeting to get started.', + 'items' => $items, + 'icon' => 'meet-icons/schedule.svg', + 'iconBg' => 'bg-violet-100', + ])
{{ $rooms->links() }}
diff --git a/resources/views/meet/spaces/create.blade.php b/resources/views/meet/spaces/create.blade.php index 51fbd0f..09b5134 100644 --- a/resources/views/meet/spaces/create.blade.php +++ b/resources/views/meet/spaces/create.blade.php @@ -33,7 +33,11 @@
- +

Comma-separated addresses. Each listener receives a join link by email.

+ + @error('invite_emails') +

{{ $message }}

+ @enderror
diff --git a/resources/views/meet/spaces/index.blade.php b/resources/views/meet/spaces/index.blade.php index 2c11a13..bfb41f0 100644 --- a/resources/views/meet/spaces/index.blade.php +++ b/resources/views/meet/spaces/index.blade.php @@ -1,22 +1,17 @@ -
-
-

Rooms

-

Audio-only discussions with a host, assigned speakers, and listeners.

-
- Create room -
- -
- @forelse ($spaces as $room) - @include('meet.partials.room-card', [ - 'room' => $room, - 'detailsRoute' => route('meet.spaces.show', $room), - ]) - @empty -

No rooms yet. Create one to host a private discussion.

- @endforelse -
+ @include('meet.partials.index-page', [ + 'badge' => 'Audio · Speakers · Listeners', + 'title' => 'Your rooms', + 'description' => 'Host private audio discussions, assign speakers, invite listeners by email, and promote people to speak during the live session.', + 'createUrl' => route('meet.spaces.create'), + 'createLabel' => 'Create room', + 'stats' => $stats, + 'listTitle' => 'Your rooms', + 'emptyMessage' => 'No rooms yet. Create your first room to host a private discussion.', + 'items' => $items, + 'icon' => 'meet-icons/speak.svg', + 'iconBg' => 'bg-emerald-100', + ])
{{ $spaces->links() }}
diff --git a/resources/views/meet/spaces/show.blade.php b/resources/views/meet/spaces/show.blade.php index e64d992..a42a591 100644 --- a/resources/views/meet/spaces/show.blade.php +++ b/resources/views/meet/spaces/show.blade.php @@ -6,6 +6,12 @@

{{ config('meet.room_statuses')[$room->status] ?? $room->status }}

+ @if (session('success')) +
+ {{ session('success') }} +
+ @endif + @if ($errors->any())
{{ $errors->first() }} @@ -36,7 +42,6 @@ @endif QR code - Manage invitations
@if ($speakers->isNotEmpty()) @@ -59,6 +64,52 @@ @endif
+
+
+
+

Listener invitations

+

Email join links to listeners. Invited people can enter private rooms.

+
+ Bulk import +
+ +
+ @csrf +
+ + + @error('emails') +

{{ $message }}

+ @enderror +
+ +
+ + @if ($room->invitations->isNotEmpty()) + + @if ($room->invitations->count() > 8) + + View all {{ $room->invitations->count() }} invitations + + @endif + @else +

No listener invitations sent yet.

+ @endif +
+ @if ($room->sessions->isNotEmpty())

Past sessions

diff --git a/resources/views/meet/webinars/index.blade.php b/resources/views/meet/webinars/index.blade.php index 30dc2a8..b13ec44 100644 --- a/resources/views/meet/webinars/index.blade.php +++ b/resources/views/meet/webinars/index.blade.php @@ -1,22 +1,17 @@ -
-
-

Webinars

-

Stage-mode events billed at GHS {{ number_format(config('meet.billing.price_per_participant_ghs', 0.30), 2) }} per participant.

-
- Schedule webinar -
- -
- @forelse ($webinars as $room) - @include('meet.partials.room-card', [ - 'room' => $room, - 'detailsRoute' => route('meet.webinars.show', $room), - ]) - @empty -

No webinars yet.

- @endforelse -
+ @include('meet.partials.index-page', [ + 'badge' => 'Stage mode · Q&A · Registration', + 'title' => 'Your webinars', + 'description' => 'Host stage-mode events for large audiences. Billed at GHS '.number_format(config('meet.billing.price_per_participant_ghs', 0.30), 2).' per participant.', + 'createUrl' => route('meet.webinars.create'), + 'createLabel' => 'Schedule webinar', + 'stats' => $stats, + 'listTitle' => 'Your webinars', + 'emptyMessage' => 'No webinars yet. Schedule your first webinar to get started.', + 'items' => $items, + 'icon' => 'meet-icons/webinar.svg', + 'iconBg' => 'bg-indigo-100', + ])
{{ $webinars->links() }}
diff --git a/tests/Feature/MeetWebTest.php b/tests/Feature/MeetWebTest.php index 1c2aa04..4e4d85c 100644 --- a/tests/Feature/MeetWebTest.php +++ b/tests/Feature/MeetWebTest.php @@ -1214,6 +1214,107 @@ class MeetWebTest extends TestCase ]); } + public function test_space_create_sends_listener_email_invitations(): void + { + $this->actingAs($this->user) + ->post('/rooms', [ + 'title' => 'Listener room', + 'invite_emails' => 'listener@example.com, bad-email', + 'invite_only' => true, + ]) + ->assertSessionHasErrors('invite_emails'); + + $this->actingAs($this->user) + ->post('/rooms', [ + 'title' => 'Listener room', + 'invite_emails' => 'listener@example.com, guest@example.com', + 'invite_only' => true, + ]) + ->assertRedirect(); + + $room = Room::where('title', 'Listener room')->firstOrFail(); + + $this->assertDatabaseHas('meet_invitations', [ + 'room_id' => $room->id, + 'email' => 'listener@example.com', + 'role' => 'attendee', + 'status' => 'pending', + ]); + + $this->assertDatabaseHas('meet_invitations', [ + 'room_id' => $room->id, + 'email' => 'guest@example.com', + 'role' => 'attendee', + ]); + } + + public function test_private_space_denies_uninvited_join(): void + { + $room = Room::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'host_user_ref' => $this->user->public_id, + 'title' => 'Private space', + 'type' => 'space', + 'status' => 'scheduled', + 'scheduled_at' => now()->addHour(), + 'timezone' => 'UTC', + 'settings' => array_merge(config('meet.default_settings'), [ + 'audio_only' => true, + 'invite_only' => true, + ]), + ]); + + $stranger = User::create([ + 'public_id' => 'stranger-001', + 'name' => 'Stranger', + 'email' => 'stranger@example.com', + ]); + + $this->actingAs($stranger) + ->get('/r/'.$room->uuid) + ->assertOk() + ->assertSee('This room is private'); + } + + public function test_private_space_allows_invited_email_to_join(): void + { + $room = Room::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'host_user_ref' => $this->user->public_id, + 'title' => 'Invited space', + 'type' => 'space', + 'status' => 'scheduled', + 'scheduled_at' => now()->addHour(), + 'timezone' => 'UTC', + 'settings' => array_merge(config('meet.default_settings'), [ + 'audio_only' => true, + 'invite_only' => true, + ]), + ]); + + \App\Models\Invitation::create([ + 'owner_ref' => $this->user->public_id, + 'room_id' => $room->id, + 'email' => 'invited@example.com', + 'role' => 'attendee', + 'status' => 'pending', + 'rsvp_token' => 'test-token', + ]); + + $invited = User::create([ + 'public_id' => 'invited-001', + 'name' => 'Invited Listener', + 'email' => 'invited@example.com', + ]); + + $this->actingAs($invited) + ->get('/r/'.$room->uuid) + ->assertOk() + ->assertDontSee('This room is private'); + } + public function test_space_host_cannot_create_breakouts(): void { $room = Room::create([