diff --git a/.env.example b/.env.example index 62e8d8c..5cc7faf 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/Http/Controllers/Api/V1/ServiceMeetController.php b/app/Http/Controllers/Api/V1/ServiceMeetController.php new file mode 100644 index 0000000..5758ae3 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ServiceMeetController.php @@ -0,0 +1,49 @@ +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]); + } +} diff --git a/app/Http/Middleware/AuthenticateService.php b/app/Http/Middleware/AuthenticateService.php new file mode 100644 index 0000000..b11c185 --- /dev/null +++ b/app/Http/Middleware/AuthenticateService.php @@ -0,0 +1,33 @@ +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); + } +} diff --git a/app/Services/Meet/EventMeetRoomLinkService.php b/app/Services/Meet/EventMeetRoomLinkService.php new file mode 100644 index 0000000..9db8137 --- /dev/null +++ b/app/Services/Meet/EventMeetRoomLinkService.php @@ -0,0 +1,148 @@ +> + */ + 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 $payload + * @return array + */ + 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 */ + 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, + ]; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index a32e4a0..d930734 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -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 { diff --git a/config/events.php b/config/events.php new file mode 100644 index 0000000..cbc2b09 --- /dev/null +++ b/config/events.php @@ -0,0 +1,11 @@ + [ + 'meet' => env('EVENTS_API_KEY_MEET', env('MEET_API_KEY_EVENTS')), + ], + + 'meet_app_url' => env('LADILL_MEET_APP_URL', 'https://meet.ladill.com'), + +]; diff --git a/routes/api.php b/routes/api.php index f8eacc3..cadfc04 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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); diff --git a/tests/Feature/ServiceMeetLinkTest.php b/tests/Feature/ServiceMeetLinkTest.php new file mode 100644 index 0000000..95b4824 --- /dev/null +++ b/tests/Feature/ServiceMeetLinkTest.php @@ -0,0 +1,90 @@ + '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); + } +}