Add Meet video visit scheduling for Care appointments.
Deploy Ladill Care / deploy (push) Successful in 38s

Schedule and start video visits via the Meet service API, persist join links on appointments, and propagate the shared copy-button component.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-03 22:53:42 +00:00
co-authored by Cursor
parent 661e6ef0e8
commit 2d06c92ddb
13 changed files with 473 additions and 3 deletions
+4
View File
@@ -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
@@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\Appointment;
use App\Services\Care\AuditLogger;
use App\Services\Meet\MeetClient;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class AppointmentMeetController extends Controller
{
use ScopesToAccount;
public function schedule(Request $request, Appointment $appointment): RedirectResponse
{
$this->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));
}
}
+2 -1
View File
@@ -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
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace App\Services\Meet;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;
/**
* Client for the Ladill Meet service API schedule rooms linked to Care records.
*/
class MeetClient
{
public function __construct(private readonly string $ownerRef) {}
public static function for(string $ownerRef): self
{
return new self($ownerRef);
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed>
*/
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();
}
}
+1
View File
@@ -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',
+16
View File
@@ -0,0 +1,16 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Ladill Meet service
|--------------------------------------------------------------------------
| Video meetings at meet.ladill.com. Care schedules video visits via the
| service API (App\Services\Meet\MeetClient).
*/
'url' => rtrim((string) env('MEET_API_URL', 'https://meet.ladill.com/api/service/v1'), '/'),
'key' => env('MEET_API_KEY_CARE'),
];
@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('care_appointments', function (Blueprint $table) {
$table->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']);
});
}
};
+2
View File
@@ -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 = {}) => ({
+57
View File
@@ -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);
},
}));
}
@@ -25,9 +25,27 @@
<button type="submit" class="btn-primary">Start consultation</button>
</form>
@endif
@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)
<a href="{{ $appointment->meet_join_url }}" target="_blank" rel="noopener" class="btn-primary">Join video visit</a>
@else
<form method="POST" action="{{ route('care.appointments.start-meet', $appointment) }}">
@csrf
<button type="submit" class="btn-primary">Start video visit</button>
</form>
@endif
@endif
@if ($canManage && $appointment->status === \App\Models\Appointment::STATUS_SCHEDULED && ! $appointment->meet_join_url)
<form method="POST" action="{{ route('care.appointments.schedule-meet', $appointment) }}">
@csrf
<button type="submit" class="rounded-lg border border-sky-200 px-4 py-2 text-sm text-sky-700 hover:bg-sky-50">Schedule video visit</button>
</form>
@endif
@if ($canManage && in_array($appointment->status, [\App\Models\Appointment::STATUS_WAITING, \App\Models\Appointment::STATUS_CHECKED_IN], true))
@if ($appointment->consultation)
<a href="{{ route('care.consultations.show', $appointment->consultation) }}" class="rounded-lg border border-sky-200 px-4 py-2 text-sm text-sky-700 hover:bg-sky-50">View consultation</a>
@endif
@endif
@if ($canManage && ! in_array($appointment->status, [\App\Models\Appointment::STATUS_COMPLETED, \App\Models\Appointment::STATUS_CANCELLED], true))
<form method="POST" action="{{ route('care.appointments.cancel', $appointment) }}" onsubmit="return confirm('Cancel this appointment?')">
@csrf
@@ -45,6 +63,9 @@
<div><dt class="text-slate-500">Branch</dt><dd class="font-medium">{{ $appointment->branch?->name ?? '—' }}</dd></div>
<div><dt class="text-slate-500">Department</dt><dd class="font-medium">{{ $appointment->department?->name ?? '—' }}</dd></div>
<div><dt class="text-slate-500">Type</dt><dd class="font-medium capitalize">{{ str_replace('_', ' ', $appointment->type) }}</dd></div>
@if ($appointment->meet_join_url)
<div><dt class="text-slate-500">Video visit</dt><dd class="font-medium flex flex-wrap items-center gap-2"><a href="{{ $appointment->meet_join_url }}" target="_blank" rel="noopener" class="text-sky-600">Join link</a><x-copy-button :text="$appointment->meet_join_url" icon copied-class="text-emerald-600" class="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600" /></dd></div>
@endif
<div><dt class="text-slate-500">Reason</dt><dd class="font-medium">{{ $appointment->reason ?? '—' }}</dd></div>
@if ($appointment->notes)
<div><dt class="text-slate-500">Notes</dt><dd class="font-medium">{{ $appointment->notes }}</dd></div>
@@ -0,0 +1,29 @@
@props([
'text' => '',
'label' => 'Copy',
'copiedLabel' => 'Copied!',
'icon' => false,
'copiedClass' => '',
])
<button
type="button"
x-data="copyButton(@js($text))"
@click="copy()"
:title="copied ? @js($copiedLabel) : @js($icon ? 'Copy' : $label)"
:class="copied ? @js($copiedClass) : ''"
{{ $attributes->class($icon ? 'inline-flex shrink-0 items-center justify-center' : '') }}
>
@if ($icon)
<svg x-show="!copied" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<svg x-show="copied" x-cloak class="h-4 w-4 text-emerald-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
</svg>
@elseif ($slot->isNotEmpty())
{{ $slot }}
@else
<span x-text="copied ? @js($copiedLabel) : @js($label)"></span>
@endif
</button>
+3
View File
@@ -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');
+148
View File
@@ -0,0 +1,148 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Appointment;
use App\Models\Branch;
use App\Models\Department;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Practitioner;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class CareAppointmentMeetTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Organization $organization;
protected Branch $branch;
protected Patient $patient;
protected function setUp(): void
{
parent::setUp();
$this->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',
]);
}
}