Extend service API for Events and fix panel layout reverting during edit.
Deploy Ladill Meet / deploy (push) Successful in 48s

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 <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-01 22:08:01 +00:00
co-authored by Cursor
parent d0b6e9ff01
commit c108514b27
8 changed files with 347 additions and 51 deletions
@@ -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<string, mixed> */
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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $validated */
private function resolveType(array $validated): string
{
if (! empty($validated['type'])) {
return (string) $validated['type'];
}
return empty($validated['scheduled_at']) ? 'instant' : 'scheduled';
}
/** @param array<string, mixed> $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);
}
}
+13
View File
@@ -76,6 +76,19 @@ class Room extends Model
return $this->hasMany(SessionFile::class, 'room_id');
}
/**
* @param \Illuminate\Database\Eloquent\Builder<self> $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';
+31 -1
View File
@@ -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<string, mixed> $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();
+4
View File
@@ -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)) {