diff --git a/.env.example b/.env.example index 3ccefa5..4b09932 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,10 @@ BILLING_API_KEY_CARE= IDENTITY_API_URL=https://ladill.com/api IDENTITY_API_KEY_CARE= +# Ladill Meet (video visits) +MEET_API_URL=https://meet.ladill.com/api/service/v1 +MEET_API_KEY_CARE= + # Afia in-app assistant (uses platform relay when AFIA_API_KEY is empty) AFIA_ENABLED=true AFIA_PRODUCT=care diff --git a/app/Http/Controllers/Care/AppointmentMeetController.php b/app/Http/Controllers/Care/AppointmentMeetController.php new file mode 100644 index 0000000..46366cd --- /dev/null +++ b/app/Http/Controllers/Care/AppointmentMeetController.php @@ -0,0 +1,86 @@ +authorizeAbility($request, 'appointments.manage'); + $this->authorizeOwner($request, $appointment); + + if ($appointment->meet_join_url) { + return redirect()->route('care.appointments.show', $appointment) + ->with('success', 'Video visit is already scheduled.'); + } + + $appointment->loadMissing('patient'); + $patient = $appointment->patient; + $owner = $this->ownerRef($request); + + $response = MeetClient::for($owner)->createRoom([ + 'title' => 'Video visit — '.$patient->fullName(), + 'description' => $appointment->reason, + 'scheduled_at' => ($appointment->scheduled_at ?? now()->addHour())->toIso8601String(), + 'duration_minutes' => 30, + 'branch_id' => $appointment->branch_id, + 'source' => [ + 'app' => 'care', + 'entity_type' => 'appointment', + 'entity_id' => $appointment->uuid, + ], + 'invite_emails' => array_values(array_filter([$patient->email])), + ]); + + $appointment->update([ + 'meet_room_uuid' => data_get($response, 'room.uuid'), + 'meet_join_url' => data_get($response, 'join_url'), + ]); + + AuditLogger::record($owner, 'appointment.meet_scheduled', $appointment->organization_id, $owner, Appointment::class, $appointment->id); + + return redirect()->route('care.appointments.show', $appointment) + ->with('success', 'Video visit scheduled. The patient will receive a join link.'); + } + + public function start(Request $request, Appointment $appointment): RedirectResponse + { + $this->authorizeAbility($request, 'consultations.manage'); + $this->authorizeOwner($request, $appointment); + + $owner = $this->ownerRef($request); + $appointment->loadMissing('patient'); + + if (! $appointment->meet_room_uuid) { + $response = MeetClient::for($owner)->createRoom([ + 'title' => 'Video visit — '.$appointment->patient->fullName(), + 'description' => $appointment->reason, + 'source' => [ + 'app' => 'care', + 'entity_type' => 'appointment', + 'entity_id' => $appointment->uuid, + ], + 'invite_emails' => array_values(array_filter([$appointment->patient->email])), + ]); + + $appointment->update([ + 'meet_room_uuid' => data_get($response, 'room.uuid'), + 'meet_join_url' => data_get($response, 'join_url'), + ]); + } + + $start = MeetClient::for($owner)->startRoom((string) $appointment->meet_room_uuid, $owner); + + return redirect()->away((string) (data_get($start, 'join_url') ?: $appointment->meet_join_url)); + } +} diff --git a/app/Models/Appointment.php b/app/Models/Appointment.php index bdbf9d7..e950025 100644 --- a/app/Models/Appointment.php +++ b/app/Models/Appointment.php @@ -37,7 +37,8 @@ class Appointment extends Model 'uuid', 'owner_ref', 'organization_id', 'branch_id', 'patient_id', 'practitioner_id', 'department_id', 'visit_id', 'type', 'status', 'scheduled_at', 'checked_in_at', 'waiting_at', 'started_at', - 'completed_at', 'cancelled_at', 'queue_position', 'reason', 'notes', 'created_by', + 'completed_at', 'cancelled_at', 'queue_position', 'reason', 'notes', + 'meet_room_uuid', 'meet_join_url', 'created_by', ]; protected function casts(): array diff --git a/app/Services/Meet/MeetClient.php b/app/Services/Meet/MeetClient.php new file mode 100644 index 0000000..d1af9a7 --- /dev/null +++ b/app/Services/Meet/MeetClient.php @@ -0,0 +1,79 @@ + $data + * @return array + */ + public function createRoom(array $data): array + { + return $this->post('rooms', array_merge($data, [ + 'owner_ref' => $this->ownerRef, + 'host_user_ref' => $data['host_user_ref'] ?? $this->ownerRef, + ])); + } + + /** + * @return array + */ + public function startRoom(string $roomUuid, ?string $hostUserRef = null): array + { + return $this->post("rooms/{$roomUuid}/start", [ + 'host_user_ref' => $hostUserRef ?? $this->ownerRef, + ]); + } + + private function client(): PendingRequest + { + return Http::baseUrl((string) config('meet.url')) + ->withToken((string) config('meet.key')) + ->acceptJson() + ->asJson() + ->connectTimeout(10) + ->timeout(20); + } + + /** + * @return array + */ + private function post(string $path, array $data): array + { + try { + $response = $this->client()->post($path, $data); + } catch (ConnectionException) { + throw ValidationException::withMessages([ + 'meet' => ['Could not reach the Meet service. Please try again in a moment.'], + ]); + } + + if ($response->status() === 422) { + throw ValidationException::withMessages( + $response->json('errors') ?: ['meet' => [$response->json('message') ?: 'Request failed.']] + ); + } + + if ($response->failed()) { + abort($response->status() === 404 ? 404 : 502, 'Meet service error.'); + } + + return (array) $response->json(); + } +} diff --git a/config/care.php b/config/care.php index 6cc8859..e6634fb 100644 --- a/config/care.php +++ b/config/care.php @@ -48,6 +48,7 @@ return [ 'appointment.completed' => 'Appointment completed', 'appointment.cancelled' => 'Appointment cancelled', 'appointment.no_show' => 'Patient marked no-show', + 'appointment.meet_scheduled' => 'Video visit scheduled', 'visit.checked_in' => 'Visit opened', 'visit.completed' => 'Visit completed', 'consultation.started' => 'Consultation started', diff --git a/config/meet.php b/config/meet.php new file mode 100644 index 0000000..cf2e675 --- /dev/null +++ b/config/meet.php @@ -0,0 +1,16 @@ + rtrim((string) env('MEET_API_URL', 'https://meet.ladill.com/api/service/v1'), '/'), + 'key' => env('MEET_API_KEY_CARE'), + +]; diff --git a/database/migrations/2026_07_05_100000_add_meet_fields_to_care_appointments.php b/database/migrations/2026_07_05_100000_add_meet_fields_to_care_appointments.php new file mode 100644 index 0000000..ad1ba05 --- /dev/null +++ b/database/migrations/2026_07_05_100000_add_meet_fields_to_care_appointments.php @@ -0,0 +1,23 @@ +uuid('meet_room_uuid')->nullable()->after('notes'); + $table->string('meet_join_url', 2048)->nullable()->after('meet_room_uuid'); + }); + } + + public function down(): void + { + Schema::table('care_appointments', function (Blueprint $table) { + $table->dropColumn(['meet_room_uuid', 'meet_join_url']); + }); + } +}; diff --git a/resources/js/app.js b/resources/js/app.js index f9cf3da..7f0694c 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,7 +1,9 @@ import Alpine from 'alpinejs'; +import { registerLadillClipboard } from './ladill-clipboard'; import collapse from '@alpinejs/collapse'; Alpine.plugin(collapse); +registerLadillClipboard(Alpine); // In-app notification bell + dropdown. Alpine.data('notificationDropdown', (config = {}) => ({ diff --git a/resources/js/ladill-clipboard.js b/resources/js/ladill-clipboard.js new file mode 100644 index 0000000..ac19b3c --- /dev/null +++ b/resources/js/ladill-clipboard.js @@ -0,0 +1,57 @@ +const COPY_FEEDBACK_MS = 2000; + +export async function writeClipboardText(text) { + const value = String(text ?? ''); + if (!value) { + return false; + } + + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + + return true; + } + } catch { + // fall through to legacy copy + } + + try { + const textarea = document.createElement('textarea'); + textarea.value = value; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(textarea); + + return ok; + } catch { + return false; + } +} + +export function registerLadillClipboard(Alpine) { + Alpine.data('copyButton', (text = '') => ({ + copied: false, + text: text ?? '', + resetTimer: null, + async copy() { + const ok = await writeClipboardText(this.text); + if (!ok) { + return; + } + + this.copied = true; + + if (this.resetTimer) { + clearTimeout(this.resetTimer); + } + + this.resetTimer = setTimeout(() => { + this.copied = false; + }, COPY_FEEDBACK_MS); + }, + })); +} diff --git a/resources/views/care/appointments/show.blade.php b/resources/views/care/appointments/show.blade.php index 1a6bc41..a49b659 100644 --- a/resources/views/care/appointments/show.blade.php +++ b/resources/views/care/appointments/show.blade.php @@ -25,8 +25,26 @@ @endif - @if ($appointment->consultation) - View consultation + @if ($canConsult && in_array($appointment->status, [\App\Models\Appointment::STATUS_WAITING, \App\Models\Appointment::STATUS_CHECKED_IN, \App\Models\Appointment::STATUS_IN_CONSULTATION], true)) + @if ($appointment->meet_join_url) + Join video visit + @else +
+ @csrf + +
+ @endif + @endif + @if ($canManage && $appointment->status === \App\Models\Appointment::STATUS_SCHEDULED && ! $appointment->meet_join_url) +
+ @csrf + +
+ @endif + @if ($canManage && in_array($appointment->status, [\App\Models\Appointment::STATUS_WAITING, \App\Models\Appointment::STATUS_CHECKED_IN], true)) + @if ($appointment->consultation) + View consultation + @endif @endif @if ($canManage && ! in_array($appointment->status, [\App\Models\Appointment::STATUS_COMPLETED, \App\Models\Appointment::STATUS_CANCELLED], true))
@@ -45,6 +63,9 @@
Branch
{{ $appointment->branch?->name ?? '—' }}
Department
{{ $appointment->department?->name ?? '—' }}
Type
{{ str_replace('_', ' ', $appointment->type) }}
+ @if ($appointment->meet_join_url) +
Video visit
Join link
+ @endif
Reason
{{ $appointment->reason ?? '—' }}
@if ($appointment->notes)
Notes
{{ $appointment->notes }}
diff --git a/resources/views/components/copy-button.blade.php b/resources/views/components/copy-button.blade.php new file mode 100644 index 0000000..06279be --- /dev/null +++ b/resources/views/components/copy-button.blade.php @@ -0,0 +1,29 @@ +@props([ + 'text' => '', + 'label' => 'Copy', + 'copiedLabel' => 'Copied!', + 'icon' => false, + 'copiedClass' => '', +]) + + diff --git a/routes/web.php b/routes/web.php index 49e051d..68da85c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Care\AiController; use App\Http\Controllers\NotificationController; use App\Http\Controllers\Care\AuditLogController; use App\Http\Controllers\Care\AppointmentController; +use App\Http\Controllers\Care\AppointmentMeetController; use App\Http\Controllers\Care\BillController; use App\Http\Controllers\Care\BranchController; use App\Http\Controllers\Care\ConsultationController; @@ -69,6 +70,8 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::post('/appointments/{appointment}/check-in', [AppointmentController::class, 'checkIn'])->name('care.appointments.check-in'); Route::post('/appointments/{appointment}/cancel', [AppointmentController::class, 'cancel'])->name('care.appointments.cancel'); Route::post('/appointments/{appointment}/no-show', [AppointmentController::class, 'noShow'])->name('care.appointments.no-show'); + Route::post('/appointments/{appointment}/schedule-meet', [AppointmentMeetController::class, 'schedule'])->name('care.appointments.schedule-meet'); + Route::post('/appointments/{appointment}/start-meet', [AppointmentMeetController::class, 'start'])->name('care.appointments.start-meet'); Route::get('/queue', [QueueController::class, 'index'])->name('care.queue.index'); Route::post('/queue/{appointment}/start', [QueueController::class, 'start'])->name('care.queue.start'); diff --git a/tests/Feature/CareAppointmentMeetTest.php b/tests/Feature/CareAppointmentMeetTest.php new file mode 100644 index 0000000..f10880e --- /dev/null +++ b/tests/Feature/CareAppointmentMeetTest.php @@ -0,0 +1,148 @@ +withoutMiddleware(EnsurePlatformSession::class); + + config([ + 'meet.url' => 'https://meet.test/api/service/v1', + 'meet.key' => 'test-care-key', + ]); + + $this->user = User::create([ + 'public_id' => 'test-user-meet', + 'name' => 'Test User', + 'email' => 'meet@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->user->public_id, + 'name' => 'Test Clinic', + 'slug' => 'test-clinic-meet', + 'timezone' => 'UTC', + 'settings' => ['onboarded' => true], + ]); + + Member::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->user->public_id, + 'role' => 'receptionist', + ]); + + $this->branch = Branch::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Main Branch', + 'is_active' => true, + ]); + + Department::create([ + 'owner_ref' => $this->user->public_id, + 'branch_id' => $this->branch->id, + 'name' => 'General Outpatient', + 'type' => 'outpatient', + 'is_active' => true, + ]); + + $this->patient = Patient::create([ + 'uuid' => (string) \Illuminate\Support\Str::uuid(), + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'LC-2026-00099', + 'first_name' => 'Ama', + 'last_name' => 'Mensah', + 'email' => 'ama@example.com', + ]); + } + + public function test_can_schedule_video_visit_for_appointment(): void + { + Http::fake([ + 'meet.test/*' => Http::response([ + 'room' => ['uuid' => 'room-uuid-1'], + 'join_url' => 'https://meet.test/join/abc', + ], 201), + ]); + + $appointment = $this->createAppointment(); + + $this->actingAs($this->user) + ->post(route('care.appointments.schedule-meet', $appointment)) + ->assertRedirect(route('care.appointments.show', $appointment)); + + $appointment->refresh(); + $this->assertSame('room-uuid-1', $appointment->meet_room_uuid); + $this->assertSame('https://meet.test/join/abc', $appointment->meet_join_url); + $this->assertDatabaseHas('care_audit_logs', ['action' => 'appointment.meet_scheduled']); + } + + public function test_can_start_instant_video_visit(): void + { + Http::fake([ + 'meet.test/*' => Http::sequence() + ->push([ + 'room' => ['uuid' => 'room-uuid-2'], + 'join_url' => 'https://meet.test/join/live', + ], 201) + ->push([ + 'join_url' => 'https://meet.test/join/live-now', + ], 200), + ]); + + Member::where('user_ref', $this->user->public_id)->update(['role' => 'doctor']); + + $appointment = $this->createAppointment(Appointment::STATUS_WAITING); + + $this->actingAs($this->user) + ->post(route('care.appointments.start-meet', $appointment)) + ->assertRedirect('https://meet.test/join/live-now'); + + $appointment->refresh(); + $this->assertSame('room-uuid-2', $appointment->meet_room_uuid); + } + + protected function createAppointment(string $status = Appointment::STATUS_SCHEDULED): Appointment + { + return Appointment::create([ + 'uuid' => (string) \Illuminate\Support\Str::uuid(), + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'type' => Appointment::TYPE_SCHEDULED, + 'status' => $status, + 'scheduled_at' => now()->addHour(), + 'reason' => 'Follow-up', + ]); + } +}