From c108514b27e2d6711f0e900172543bb6c7d8adef Mon Sep 17 00:00:00 2001 From: isaacclad Date: Wed, 1 Jul 2026 22:08:01 +0000 Subject: [PATCH] Extend service API for Events and fix panel layout reverting during edit. Service rooms support town_hall/webinar types with source lookup, update, and cancel; stage poll no longer overwrites layout while the modal is open. Co-authored-by: Cursor --- .../Api/V1/ServiceRoomController.php | 255 +++++++++++++++--- app/Models/Room.php | 13 + app/Services/Meet/RoomService.php | 32 ++- app/Services/Meet/StageLayoutService.php | 4 + resources/js/meet-room.js | 22 +- resources/views/meet/room/show.blade.php | 2 +- routes/api.php | 3 + tests/Feature/MeetWebTest.php | 67 +++++ 8 files changed, 347 insertions(+), 51 deletions(-) diff --git a/app/Http/Controllers/Api/V1/ServiceRoomController.php b/app/Http/Controllers/Api/V1/ServiceRoomController.php index 82fcedb..fc897f2 100644 --- a/app/Http/Controllers/Api/V1/ServiceRoomController.php +++ b/app/Http/Controllers/Api/V1/ServiceRoomController.php @@ -13,9 +13,12 @@ use App\Services\Meet\RoomService; use App\Services\Meet\SessionService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Validation\Rule; class ServiceRoomController extends Controller { + private const SERVICE_TYPES = ['instant', 'scheduled', 'town_hall', 'webinar']; + public function __construct( protected RoomService $rooms, protected SessionService $sessions, @@ -26,60 +29,85 @@ class ServiceRoomController extends Controller public function store(Request $request): JsonResponse { - $validated = $request->validate([ - 'owner_ref' => ['required', 'string'], - 'organization_id' => ['nullable', 'integer', 'exists:meet_organizations,id'], - 'host_user_ref' => ['required', 'string'], - 'title' => ['required', 'string', 'max:255'], - 'description' => ['nullable', 'string'], - 'scheduled_at' => ['nullable', 'date'], - 'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'], - 'passcode' => ['nullable', 'string', 'max:20'], - 'settings' => ['nullable', 'array'], - 'source' => ['required', 'array'], - 'source.app' => ['required', 'string'], - 'source.entity_type' => ['nullable', 'string'], - 'source.entity_id' => ['nullable', 'string'], - 'invite_emails' => ['nullable', 'array'], - 'invite_emails.*' => ['email'], - ]); + $validated = $this->validateRoomPayload($request); - $organization = isset($validated['organization_id']) - ? Organization::findOrFail($validated['organization_id']) - : Organization::owned($validated['owner_ref'])->firstOrFail(); + $organization = $this->resolveOrganization($validated); abort_unless($organization->owner_ref === $validated['owner_ref'], 403); - $host = User::where('public_id', $validated['host_user_ref'])->firstOrFail(); + $entityId = (string) ($validated['source']['entity_id'] ?? ''); + if ($entityId !== '') { + $existing = Room::query() + ->forSource((string) $validated['source']['app'], $entityId, $validated['owner_ref']) + ->first(); - $room = empty($validated['scheduled_at']) - ? $this->rooms->createInstant($host, $organization, $validated) - : $this->rooms->createScheduled($host, $organization, $validated); - - if (! empty($validated['invite_emails'])) { - $invites = collect($validated['invite_emails'])->map(fn ($email) => ['email' => $email])->all(); - $this->invitations->inviteToRoom($room, $invites, $host); + if ($existing) { + return $this->respondWithRoom( + $this->persistRoomUpdate($existing, $validated, $organization), + ); + } } - if ($room->scheduled_at) { - $this->calendar->syncRoomCreated($room, $host); + return $this->respondWithRoom( + $this->persistRoomCreate($validated, $organization), + 201, + ); + } + + public function lookup(Request $request): JsonResponse + { + $validated = $request->validate([ + 'source_app' => ['required', 'string'], + 'entity_id' => ['required', 'string'], + 'owner_ref' => ['nullable', 'string'], + ]); + + $room = Room::query() + ->forSource( + $validated['source_app'], + $validated['entity_id'], + $validated['owner_ref'] ?? null, + ) + ->first(); + + if (! $room) { + return response()->json(['error' => 'Room not found.'], 404); } - $this->webhooks->roomCreated($room); - - return response()->json([ - 'room' => $room->fresh(), - 'join_url' => $room->joinUrl(), - 'embed_url' => route('meet.join', $room), - ], 201); + return $this->respondWithRoom($room); } public function show(Room $room): JsonResponse { - return response()->json([ - 'room' => $room->load('sessions'), - 'join_url' => $room->joinUrl(), - 'embed_url' => route('meet.join', $room), + return $this->respondWithRoom($room->load('sessions')); + } + + public function update(Request $request, Room $room): JsonResponse + { + $validated = $this->validateRoomPayload($request, partial: true); + abort_unless($room->owner_ref === ($validated['owner_ref'] ?? $room->owner_ref), 403); + + $organization = isset($validated['organization_id']) + ? Organization::findOrFail($validated['organization_id']) + : $room->organization; + + return $this->respondWithRoom( + $this->persistRoomUpdate($room, $validated, $organization), + ); + } + + public function cancel(Request $request, Room $room): JsonResponse + { + $validated = $request->validate([ + 'owner_ref' => ['required', 'string'], + 'host_user_ref' => ['nullable', 'string'], ]); + + abort_unless($room->owner_ref === $validated['owner_ref'], 403); + + $host = User::where('public_id', $validated['host_user_ref'] ?? $room->host_user_ref)->first(); + $this->rooms->cancel($room, $validated['owner_ref'], $host); + + return $this->respondWithRoom($room->fresh()); } public function start(Request $request, Room $room): JsonResponse @@ -95,4 +123,149 @@ class ServiceRoomController extends Controller 'join_url' => $room->joinUrl(), ]); } + + /** @return array */ + private function validateRoomPayload(Request $request, bool $partial = false): array + { + $required = $partial ? 'sometimes' : 'required'; + + return $request->validate([ + 'owner_ref' => [$required, 'string'], + 'organization_id' => ['nullable', 'integer', 'exists:meet_organizations,id'], + 'host_user_ref' => [$partial ? 'sometimes' : 'required', 'string'], + 'title' => [$partial ? 'sometimes' : 'required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'type' => ['nullable', 'string', Rule::in(self::SERVICE_TYPES)], + 'scheduled_at' => ['nullable', 'date'], + 'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'], + 'timezone' => ['nullable', 'timezone'], + 'passcode' => ['nullable', 'string', 'max:20'], + 'settings' => ['nullable', 'array'], + 'source' => [$partial ? 'sometimes' : 'required', 'array'], + 'source.app' => [$partial ? 'sometimes' : 'required', 'string'], + 'source.entity_type' => ['nullable', 'string'], + 'source.entity_id' => ['nullable', 'string'], + 'source.event_id' => ['nullable', 'string'], + 'invite_emails' => ['nullable', 'array'], + 'invite_emails.*' => ['email'], + ]); + } + + /** @param array $validated */ + private function resolveOrganization(array $validated): Organization + { + return isset($validated['organization_id']) + ? Organization::findOrFail($validated['organization_id']) + : Organization::owned($validated['owner_ref'])->firstOrFail(); + } + + /** @param array $validated */ + private function persistRoomCreate(array $validated, Organization $organization): Room + { + $host = User::where('public_id', $validated['host_user_ref'])->firstOrFail(); + $type = $this->resolveType($validated); + + $payload = array_merge($validated, [ + 'type' => $type, + 'status' => 'scheduled', + 'settings' => $this->mergeSettingsForType($type, $validated['settings'] ?? []), + ]); + + $room = $type === 'instant' && empty($validated['scheduled_at']) + ? $this->rooms->createInstant($host, $organization, $payload) + : $this->rooms->create($host, $organization, $payload); + + $this->afterRoomPersisted($room, $validated, $host); + + return $room->fresh(); + } + + /** @param array $validated */ + private function persistRoomUpdate(Room $room, array $validated, Organization $organization): Room + { + $hostRef = $validated['host_user_ref'] ?? $room->host_user_ref; + $host = User::where('public_id', $hostRef)->firstOrFail(); + + $update = array_filter([ + 'title' => $validated['title'] ?? null, + 'description' => array_key_exists('description', $validated) ? $validated['description'] : null, + 'scheduled_at' => $validated['scheduled_at'] ?? null, + 'duration_minutes' => $validated['duration_minutes'] ?? null, + 'timezone' => $validated['timezone'] ?? null, + 'passcode' => array_key_exists('passcode', $validated) ? $validated['passcode'] : null, + 'host_user_ref' => $hostRef, + 'source' => $validated['source'] ?? null, + ], fn ($value) => $value !== null); + + if (isset($validated['settings'])) { + $type = $this->resolveType(array_merge(['type' => $room->type], $validated)); + $update['settings'] = $this->mergeSettingsForType($type, $validated['settings']); + } + + $room = $this->rooms->update($room, $update, $host); + $this->afterRoomPersisted($room, $validated, $host); + + return $room; + } + + /** @param array $validated */ + private function afterRoomPersisted(Room $room, array $validated, User $host): void + { + if (! empty($validated['invite_emails'])) { + $invites = collect($validated['invite_emails'])->map(fn ($email) => ['email' => $email])->all(); + $this->invitations->inviteToRoom($room, $invites, $host); + } + + if ($room->scheduled_at) { + $this->calendar->syncRoomCreated($room, $host); + } + } + + /** @param array $validated */ + private function resolveType(array $validated): string + { + if (! empty($validated['type'])) { + return (string) $validated['type']; + } + + return empty($validated['scheduled_at']) ? 'instant' : 'scheduled'; + } + + /** @param array $settings */ + private function mergeSettingsForType(string $type, array $settings): array + { + $defaults = match ($type) { + 'town_hall' => [ + 'waiting_room' => true, + 'mute_on_join' => true, + 'green_room' => true, + 'stage_layout' => 'plenary', + 'stage_mode' => true, + 'panel_discussions' => false, + 'panels' => [], + 'presenter_refs' => [], + ], + 'webinar' => [ + 'waiting_room' => true, + 'mute_on_join' => true, + 'webinar_auto_approve' => false, + 'stage_layout' => 'plenary', + 'stage_mode' => true, + 'panel_discussions' => false, + 'panels' => [], + ], + default => [], + }; + + return array_merge(config('meet.default_settings', []), $defaults, $settings); + } + + private function respondWithRoom(Room $room, int $status = 200): JsonResponse + { + return response()->json([ + 'room' => $room->loadMissing('sessions'), + 'join_url' => $room->joinUrl(), + 'embed_url' => route('meet.join', $room), + ], $status); + } } diff --git a/app/Models/Room.php b/app/Models/Room.php index fb11c71..08c87a1 100644 --- a/app/Models/Room.php +++ b/app/Models/Room.php @@ -76,6 +76,19 @@ class Room extends Model return $this->hasMany(SessionFile::class, 'room_id'); } + /** + * @param \Illuminate\Database\Eloquent\Builder $query + */ + public function scopeForSource($query, string $app, string $entityId, ?string $ownerRef = null): void + { + $query->where('source->app', $app) + ->where('source->entity_id', $entityId); + + if ($ownerRef !== null) { + $query->where('owner_ref', $ownerRef); + } + } + public function isWebinar(): bool { return $this->type === 'webinar'; diff --git a/app/Services/Meet/RoomService.php b/app/Services/Meet/RoomService.php index 3fbac2f..f2dab9c 100644 --- a/app/Services/Meet/RoomService.php +++ b/app/Services/Meet/RoomService.php @@ -49,7 +49,7 @@ class RoomService 'owner_ref' => $ownerRef, 'organization_id' => $organization->id, 'branch_id' => $data['branch_id'] ?? null, - 'host_user_ref' => $ownerRef, + 'host_user_ref' => $data['host_user_ref'] ?? $ownerRef, 'title' => $data['title'], 'description' => $data['description'] ?? null, 'type' => $data['type'] ?? 'instant', @@ -70,6 +70,36 @@ class RoomService return $room; } + /** + * @param array $data + */ + public function update(Room $room, array $data, ?User $host = null): Room + { + $settings = isset($data['settings']) + ? array_merge($room->settings ?? [], $data['settings']) + : $room->settings; + + $room->update(array_filter([ + 'title' => $data['title'] ?? null, + 'description' => array_key_exists('description', $data) ? $data['description'] : null, + 'scheduled_at' => $data['scheduled_at'] ?? null, + 'duration_minutes' => $data['duration_minutes'] ?? null, + 'timezone' => $data['timezone'] ?? null, + 'passcode' => array_key_exists('passcode', $data) ? ($data['passcode'] !== null ? (string) $data['passcode'] : null) : null, + 'settings' => $settings, + 'source' => $data['source'] ?? null, + 'host_user_ref' => $data['host_user_ref'] ?? null, + ], fn ($value) => $value !== null)); + + AuditLogger::record($room->owner_ref, 'room.updated', $room->organization_id, $room->host_user_ref, Room::class, $room->id); + + if ($host && $room->scheduled_at) { + app(CalendarService::class)->syncRoomCreated($room->fresh(), $host); + } + + return $room->fresh(); + } + public function ensurePersonalRoom(User $user, Organization $organization): Room { $ownerRef = $user->ownerRef(); diff --git a/app/Services/Meet/StageLayoutService.php b/app/Services/Meet/StageLayoutService.php index f7c3ad0..7dad30d 100644 --- a/app/Services/Meet/StageLayoutService.php +++ b/app/Services/Meet/StageLayoutService.php @@ -42,6 +42,10 @@ class StageLayoutService $layout = (string) $data['stage_layout']; abort_unless(in_array($layout, ['plenary', 'panel'], true), 422); $settings['stage_layout'] = $layout; + + if ($layout === 'panel') { + $settings['panel_discussions'] = true; + } } if (array_key_exists('active_panel_id', $data)) { diff --git a/resources/js/meet-room.js b/resources/js/meet-room.js index c6d3ca9..ffe9b27 100644 --- a/resources/js/meet-room.js +++ b/resources/js/meet-room.js @@ -615,18 +615,23 @@ function meetRoom() { }, mergeStageConfig(data) { - if (typeof data.stage_layout === 'string') { - this.stageLayout = data.stage_layout; + const editingStage = this.panelSetupOpen; + + if (!editingStage) { + if (typeof data.stage_layout === 'string') { + this.stageLayout = data.stage_layout; + } + if (Array.isArray(data.panels)) { + this.panels = data.panels; + } + if (data.active_panel_id !== undefined) { + this.activePanelId = data.active_panel_id || ''; + } } + if (typeof data.panel_discussions === 'boolean') { this.panelDiscussions = data.panel_discussions; } - if (Array.isArray(data.panels)) { - this.panels = data.panels; - } - if (data.active_panel_id !== undefined) { - this.activePanelId = data.active_panel_id || ''; - } if (data.spotlight_speaker_ref !== undefined) { this.spotlightSpeakerRef = data.spotlight_speaker_ref || ''; } @@ -783,6 +788,7 @@ function meetRoom() { headers: this.headers(), body: JSON.stringify({ stage_layout: this.stageLayout, + panel_discussions: this.panelDiscussions || this.stageLayout === 'panel', panels: this.panels, active_panel_id: this.activePanelId || null, spotlight_speaker_ref: this.spotlightSpeakerRef || null, diff --git a/resources/views/meet/room/show.blade.php b/resources/views/meet/room/show.blade.php index 7b314b3..94a6eed 100644 --- a/resources/views/meet/room/show.blade.php +++ b/resources/views/meet/room/show.blade.php @@ -524,7 +524,7 @@ :class="stageLayout === 'plenary' ? 'bg-violet-600 text-white' : 'bg-slate-800 text-slate-300 hover:bg-slate-700'"> Plenary -