Add service API for Meet to list and link virtual events.
Deploy Ladill Events / deploy (push) Successful in 1m17s

Meet can attach existing conference or webinar rooms to event virtual sessions so registration stays in Events.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-02 07:38:56 +00:00
co-authored by Cursor
parent 93272f968a
commit 32c721b4f2
8 changed files with 342 additions and 0 deletions
@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\User;
use App\Services\Meet\EventMeetRoomLinkService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ServiceMeetController extends Controller
{
public function __construct(private readonly EventMeetRoomLinkService $links) {}
public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'owner_ref' => ['required', 'string', 'max:64'],
]);
$owner = User::query()->where('public_id', $validated['owner_ref'])->firstOrFail();
return response()->json([
'events' => $this->links->linkableEvents($owner),
]);
}
public function linkRoom(Request $request, QrCode $event): JsonResponse
{
$validated = $request->validate([
'owner_ref' => ['required', 'string', 'max:64'],
'meet_room_uuid' => ['required', 'string', 'max:64'],
'meet_room_type' => ['required', 'string', 'in:town_hall,webinar'],
'join_url' => ['nullable', 'string', 'max:2048'],
'title' => ['nullable', 'string', 'max:255'],
'scheduled_at' => ['nullable', 'date'],
'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'],
'virtual_session_id' => ['nullable', 'string', 'max:64'],
]);
$owner = User::query()->where('public_id', $validated['owner_ref'])->firstOrFail();
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$result = $this->links->linkRoom($owner, $event, $validated);
return response()->json(['event' => $result]);
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AuthenticateService
{
public function handle(Request $request, Closure $next, string $namespace = 'events'): Response
{
$token = (string) $request->bearerToken();
$caller = null;
if ($token !== '') {
foreach ((array) config("{$namespace}.service_api_keys", []) as $name => $key) {
if (is_string($key) && $key !== '' && hash_equals($key, $token)) {
$caller = $name;
break;
}
}
}
if ($caller === null) {
return response()->json(['error' => 'Unauthorized.'], 401);
}
$request->attributes->set('service_caller', $caller);
return $next($request);
}
}
@@ -0,0 +1,148 @@
<?php
namespace App\Services\Meet;
use App\Models\QrCode;
use App\Models\User;
use Illuminate\Support\Str;
class EventMeetRoomLinkService
{
public function __construct(private readonly EventMeetSyncService $sync) {}
/**
* @return list<array<string, mixed>>
*/
public function linkableEvents(User $owner): array
{
return QrCode::query()
->where('user_id', $owner->id)
->where('type', QrCode::TYPE_EVENT)
->orderByDesc('updated_at')
->get()
->filter(function (QrCode $event) {
$format = (string) ($event->content()['format'] ?? 'in_person');
return in_array($format, ['virtual', 'hybrid'], true);
})
->map(fn (QrCode $event) => $this->summarizeEvent($event))
->values()
->all();
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function linkRoom(User $owner, QrCode $event, array $payload): array
{
abort_unless($event->user_id === $owner->id, 403);
abort_unless($event->type === QrCode::TYPE_EVENT, 422);
$format = (string) ($event->content()['format'] ?? 'in_person');
abort_unless(in_array($format, ['virtual', 'hybrid'], true), 422, 'Only virtual or hybrid events can be linked.');
$roomUuid = trim((string) ($payload['meet_room_uuid'] ?? ''));
$roomType = in_array($payload['meet_room_type'] ?? '', ['town_hall', 'webinar'], true)
? (string) $payload['meet_room_type']
: 'town_hall';
abort_unless($roomUuid !== '', 422, 'meet_room_uuid is required.');
$joinUrl = trim((string) ($payload['join_url'] ?? ''));
$title = trim((string) ($payload['title'] ?? $event->label ?? 'Virtual session'));
$sessionId = trim((string) ($payload['virtual_session_id'] ?? ''));
$content = $event->content();
$sessions = array_values(array_filter(
(array) ($content['virtual_sessions'] ?? []),
fn ($session) => is_array($session),
));
$matchedIndex = null;
if ($sessionId !== '') {
foreach ($sessions as $index => $session) {
if ((string) ($session['id'] ?? '') === $sessionId) {
$matchedIndex = $index;
break;
}
}
}
if ($matchedIndex === null) {
foreach ($sessions as $index => $session) {
$existingUuid = trim((string) ($session['meet_room_uuid'] ?? ''));
$existingType = (string) ($session['meet_room_type'] ?? 'town_hall');
if ($existingUuid === '' && $existingType === $roomType) {
$matchedIndex = $index;
break;
}
}
}
if ($matchedIndex === null) {
$sessionId = $sessionId !== '' ? $sessionId : 'vs-'.Str::lower(Str::random(12));
$sessions[] = [
'id' => $sessionId,
'title' => $title,
'meet_room_type' => $roomType,
'scheduled_at' => $payload['scheduled_at'] ?? $content['starts_at'] ?? null,
'duration_minutes' => max(15, (int) ($payload['duration_minutes'] ?? 60)),
];
$matchedIndex = count($sessions) - 1;
} else {
$sessionId = (string) ($sessions[$matchedIndex]['id'] ?? $sessionId);
}
$sessions[$matchedIndex] = array_merge($sessions[$matchedIndex], [
'id' => $sessionId,
'title' => $title !== '' ? $title : (string) ($sessions[$matchedIndex]['title'] ?? 'Virtual session'),
'meet_room_type' => $roomType,
'meet_room_uuid' => $roomUuid,
'join_url' => $joinUrl !== '' ? $joinUrl : ($sessions[$matchedIndex]['join_url'] ?? null),
'scheduled_at' => $payload['scheduled_at'] ?? $sessions[$matchedIndex]['scheduled_at'] ?? null,
'duration_minutes' => max(15, (int) ($payload['duration_minutes'] ?? ($sessions[$matchedIndex]['duration_minutes'] ?? 60))),
]);
$payloadData = (array) ($event->payload ?? []);
$payloadData['content'] = array_merge($content, ['virtual_sessions' => $sessions]);
$event->payload = $payloadData;
$event->save();
$event = $this->sync->sync($event->fresh(), $owner);
return array_merge($this->summarizeEvent($event), [
'entity_id' => $sessionId,
'meet_room_uuid' => $roomUuid,
'join_url' => $joinUrl,
]);
}
/** @return array<string, mixed> */
private function summarizeEvent(QrCode $event): array
{
$content = $event->content();
$sessions = collect((array) ($content['virtual_sessions'] ?? []))
->filter(fn ($session) => is_array($session))
->map(fn (array $session) => [
'id' => (string) ($session['id'] ?? ''),
'title' => (string) ($session['title'] ?? ''),
'meet_room_type' => (string) ($session['meet_room_type'] ?? 'town_hall'),
'meet_room_uuid' => ($uuid = trim((string) ($session['meet_room_uuid'] ?? ''))) !== '' ? $uuid : null,
'scheduled_at' => $session['scheduled_at'] ?? null,
'duration_minutes' => (int) ($session['duration_minutes'] ?? 60),
])
->values()
->all();
return [
'id' => $event->id,
'title' => (string) ($content['title'] ?? $event->label ?? 'Event'),
'format' => (string) ($content['format'] ?? 'in_person'),
'short_code' => $event->short_code,
'registration_url' => $event->publicUrl(),
'admin_url' => url('/events/'.$event->id.'/attendees'),
'edit_url' => url('/events/'.$event->id),
'virtual_sessions' => $sessions,
];
}
}