From 46d630221a35e45d83066474485ec6bfded82361 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sat, 4 Jul 2026 02:07:27 +0000 Subject: [PATCH] Wire space listener email invites and restyle Meet index pages. Enforce invite-only room access, validate invite emails on create, assign attendee role for listener invitations, and add invite UI on the room show page. Redesign meetings, rooms, conferences, and webinars indexes to match the Events hero, stats cards, and icon list layout. Co-authored-by: Cursor --- .../Meet/Concerns/BuildsMeetIndexPage.php | 74 +++++++++++++ .../Controllers/Meet/ConferenceController.php | 8 +- app/Http/Controllers/Meet/RoomController.php | 8 +- app/Http/Controllers/Meet/SpaceController.php | 38 +++++-- .../Controllers/Meet/WebinarController.php | 8 +- app/Services/Meet/InvitationService.php | 6 +- app/Services/Meet/SecurityPolicyService.php | 40 ++++++- .../views/meet/conferences/index.blade.php | 31 +++--- .../views/meet/invitations/index.blade.php | 4 +- resources/views/meet/join/denied.blade.php | 2 +- .../views/meet/partials/index-page.blade.php | 83 ++++++++++++++ resources/views/meet/rooms/index.blade.php | 31 +++--- resources/views/meet/spaces/create.blade.php | 6 +- resources/views/meet/spaces/index.blade.php | 31 +++--- resources/views/meet/spaces/show.blade.php | 53 ++++++++- resources/views/meet/webinars/index.blade.php | 31 +++--- tests/Feature/MeetWebTest.php | 101 ++++++++++++++++++ 17 files changed, 463 insertions(+), 92 deletions(-) create mode 100644 app/Http/Controllers/Meet/Concerns/BuildsMeetIndexPage.php create mode 100644 resources/views/meet/partials/index-page.blade.php 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()) +
    + @foreach ($room->invitations->take(8) as $invitation) +
  • +
    +

    {{ $invitation->display_name ?? $invitation->email }}

    +

    {{ $invitation->email }} · {{ ucfirst($invitation->status) }}

    +
    +
    + @csrf + +
    +
  • + @endforeach +
+ @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([