Add service API for Meet to list and link virtual events.
Deploy Ladill Events / deploy (push) Successful in 1m17s
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:
@@ -43,6 +43,9 @@ MEET_API_URL=https://meet.ladill.com/api/service/v1
|
||||
MEET_API_KEY_EVENTS=
|
||||
MEET_WEBHOOK_SECRET_EVENTS=
|
||||
|
||||
# Inbound service API (Meet calls Events)
|
||||
EVENTS_API_KEY_MEET=
|
||||
|
||||
SMS_PLATFORM_API_URL=https://ladill.com/api
|
||||
SMS_API_KEY_EVENTS=
|
||||
SMS_DEFAULT_SENDER_ID=Ladill
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
$middleware->alias([
|
||||
'platform.session' => \App\Http\Middleware\EnsurePlatformSession::class,
|
||||
'redirect.legacy.qr' => \App\Http\Middleware\RedirectLegacyQrToLadillLink::class,
|
||||
'auth.service' => \App\Http\Middleware\AuthenticateService::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
'service_api_keys' => [
|
||||
'meet' => env('EVENTS_API_KEY_MEET', env('MEET_API_KEY_EVENTS')),
|
||||
],
|
||||
|
||||
'meet_app_url' => env('LADILL_MEET_APP_URL', 'https://meet.ladill.com'),
|
||||
|
||||
];
|
||||
@@ -10,6 +10,13 @@ Route::post('/ssl-callback', SslCallbackController::class)->name('api.ssl-callba
|
||||
|
||||
Route::post('/meet/webhooks', \App\Http\Controllers\Api\MeetWebhookController::class)->name('api.meet.webhooks');
|
||||
|
||||
Route::middleware(['auth.service:events'])->prefix('service/v1')->group(function () {
|
||||
Route::get('/events', [\App\Http\Controllers\Api\V1\ServiceMeetController::class, 'index'])->name('api.service.events.index');
|
||||
Route::post('/events/{event}/link-room', [\App\Http\Controllers\Api\V1\ServiceMeetController::class, 'linkRoom'])
|
||||
->whereNumber('event')
|
||||
->name('api.service.events.link-room');
|
||||
});
|
||||
|
||||
Route::middleware(['auth:sanctum', \App\Http\Middleware\SetActingAccount::class])->prefix('v1')->group(function () {
|
||||
Route::get('/me', MeController::class);
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ServiceMeetLinkTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected User $owner;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config(['events.service_api_keys.meet' => 'test-meet-key']);
|
||||
config(['meet.key' => 'test-meet-key']);
|
||||
|
||||
Http::fake([
|
||||
config('meet.url').'/rooms' => Http::response([
|
||||
'room' => ['uuid' => 'room-uuid-123'],
|
||||
'join_url' => 'https://meet.ladill.com/r/room-uuid-123',
|
||||
], 201),
|
||||
]);
|
||||
|
||||
$this->owner = User::factory()->create(['public_id' => 'usr_meet_link']);
|
||||
}
|
||||
|
||||
public function test_service_api_lists_virtual_events(): void
|
||||
{
|
||||
QrCode::create([
|
||||
'user_id' => $this->owner->id,
|
||||
'short_code' => 'evt-virtual-1',
|
||||
'type' => QrCode::TYPE_EVENT,
|
||||
'label' => 'Virtual summit',
|
||||
'payload' => [
|
||||
'content' => [
|
||||
'title' => 'Virtual summit',
|
||||
'format' => 'virtual',
|
||||
'virtual_sessions' => [],
|
||||
],
|
||||
'style' => [],
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->withToken('test-meet-key')
|
||||
->getJson('/api/service/v1/events?owner_ref='.$this->owner->public_id)
|
||||
->assertOk()
|
||||
->assertJsonPath('events.0.title', 'Virtual summit');
|
||||
}
|
||||
|
||||
public function test_service_api_links_existing_meet_room_to_event(): void
|
||||
{
|
||||
$event = QrCode::create([
|
||||
'user_id' => $this->owner->id,
|
||||
'short_code' => 'evt-link-1',
|
||||
'type' => QrCode::TYPE_EVENT,
|
||||
'label' => 'Hybrid day',
|
||||
'payload' => [
|
||||
'content' => [
|
||||
'title' => 'Hybrid day',
|
||||
'format' => 'hybrid',
|
||||
'virtual_sessions' => [],
|
||||
],
|
||||
'style' => [],
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->withToken('test-meet-key')
|
||||
->postJson('/api/service/v1/events/'.$event->id.'/link-room', [
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'meet_room_uuid' => 'room-uuid-123',
|
||||
'meet_room_type' => 'webinar',
|
||||
'join_url' => 'https://meet.ladill.com/r/room-uuid-123',
|
||||
'title' => 'Keynote stream',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('event.meet_room_uuid', 'room-uuid-123');
|
||||
|
||||
$sessions = $event->fresh()->content()['virtual_sessions'];
|
||||
$this->assertSame('room-uuid-123', $sessions[0]['meet_room_uuid'] ?? null);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user