diff --git a/app/Services/Care/AppointmentService.php b/app/Services/Care/AppointmentService.php index f1ab18a..87e13ee 100644 --- a/app/Services/Care/AppointmentService.php +++ b/app/Services/Care/AppointmentService.php @@ -6,10 +6,12 @@ use App\Models\Appointment; use App\Models\Organization; use App\Models\Patient; use App\Models\Visit; +use App\Services\Meet\MeetClient; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Log; use InvalidArgumentException; class AppointmentService @@ -237,6 +239,10 @@ class AppointmentService throw new InvalidArgumentException('Appointment cannot be cancelled.'); } + $meetRoomUuid = filled($appointment->meet_room_uuid) + ? (string) $appointment->meet_room_uuid + : null; + $appointment->update([ 'status' => Appointment::STATUS_CANCELLED, 'cancelled_at' => now(), @@ -244,9 +250,39 @@ class AppointmentService AuditLogger::record($ownerRef, 'appointment.cancelled', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id); + if ($meetRoomUuid !== null) { + $this->cancelLinkedMeetRoom($appointment, $ownerRef, $meetRoomUuid, $actorRef); + } + return $appointment->fresh(['patient', 'practitioner']); } + protected function cancelLinkedMeetRoom( + Appointment $appointment, + string $ownerRef, + string $roomUuid, + ?string $actorRef = null, + ): void { + try { + MeetClient::for($ownerRef)->cancelRoom($roomUuid, $actorRef ?? $ownerRef); + AuditLogger::record( + $ownerRef, + 'appointment.meet_cancelled', + $appointment->organization_id, + $actorRef, + Appointment::class, + $appointment->id, + ['meet_room_uuid' => $roomUuid], + ); + } catch (\Throwable $e) { + Log::warning('Care appointment cancel: could not cancel linked Meet room', [ + 'appointment_id' => $appointment->id, + 'meet_room_uuid' => $roomUuid, + 'error' => $e->getMessage(), + ]); + } + } + public function markNoShow(Appointment $appointment, string $ownerRef, ?string $actorRef = null): Appointment { $this->assertTransition($appointment, Appointment::STATUS_SCHEDULED); diff --git a/app/Services/Meet/MeetClient.php b/app/Services/Meet/MeetClient.php index cfc2e11..f6583f2 100644 --- a/app/Services/Meet/MeetClient.php +++ b/app/Services/Meet/MeetClient.php @@ -41,6 +41,17 @@ class MeetClient ]); } + /** + * @return array + */ + public function cancelRoom(string $roomUuid, ?string $hostUserRef = null): array + { + return $this->post("rooms/{$roomUuid}/cancel", [ + 'owner_ref' => $this->ownerRef, + 'host_user_ref' => $hostUserRef ?? $this->ownerRef, + ]); + } + private function client(): PendingRequest { return Http::baseUrl((string) config('meet.url')) diff --git a/config/care.php b/config/care.php index e6634fb..1883546 100644 --- a/config/care.php +++ b/config/care.php @@ -49,6 +49,7 @@ return [ 'appointment.cancelled' => 'Appointment cancelled', 'appointment.no_show' => 'Patient marked no-show', 'appointment.meet_scheduled' => 'Video visit scheduled', + 'appointment.meet_cancelled' => 'Video visit cancelled', 'visit.checked_in' => 'Visit opened', 'visit.completed' => 'Visit completed', 'consultation.started' => 'Consultation started', diff --git a/resources/views/care/appointments/show.blade.php b/resources/views/care/appointments/show.blade.php index c3bd61b..3435b48 100644 --- a/resources/views/care/appointments/show.blade.php +++ b/resources/views/care/appointments/show.blade.php @@ -50,7 +50,7 @@ diff --git a/tests/Feature/CareAppointmentMeetTest.php b/tests/Feature/CareAppointmentMeetTest.php index ddcc4a7..306ada9 100644 --- a/tests/Feature/CareAppointmentMeetTest.php +++ b/tests/Feature/CareAppointmentMeetTest.php @@ -149,6 +149,59 @@ class CareAppointmentMeetTest extends TestCase $this->assertNull($appointment->meet_room_uuid); } + public function test_cancelling_appointment_cancels_linked_meet_room(): void + { + Http::fake([ + 'meet.test/*/rooms/room-uuid-cancel/cancel' => Http::response([ + 'room' => ['uuid' => 'room-uuid-cancel', 'status' => 'cancelled'], + ], 200), + ]); + + $appointment = $this->createAppointment(); + $appointment->update([ + 'meet_room_uuid' => 'room-uuid-cancel', + 'meet_join_url' => 'https://meet.test/join/cancel-me', + ]); + + $this->actingAs($this->user) + ->post(route('care.appointments.cancel', $appointment)) + ->assertRedirect(); + + $appointment->refresh(); + $this->assertSame(Appointment::STATUS_CANCELLED, $appointment->status); + $this->assertDatabaseHas('care_audit_logs', ['action' => 'appointment.cancelled']); + $this->assertDatabaseHas('care_audit_logs', ['action' => 'appointment.meet_cancelled']); + + Http::assertSent(function ($request) { + return $request->method() === 'POST' + && str_contains($request->url(), '/rooms/room-uuid-cancel/cancel') + && $request['owner_ref'] === $this->user->public_id; + }); + } + + public function test_cancelling_appointment_still_succeeds_when_meet_cancel_fails(): void + { + Http::fake([ + 'meet.test/*' => Http::response(['error' => 'Unauthorized.'], 401), + ]); + + $appointment = $this->createAppointment(); + $appointment->update([ + 'meet_room_uuid' => 'room-uuid-orphan', + 'meet_join_url' => 'https://meet.test/join/orphan', + ]); + + $this->actingAs($this->user) + ->post(route('care.appointments.cancel', $appointment)) + ->assertRedirect() + ->assertSessionHas('success'); + + $appointment->refresh(); + $this->assertSame(Appointment::STATUS_CANCELLED, $appointment->status); + $this->assertDatabaseHas('care_audit_logs', ['action' => 'appointment.cancelled']); + $this->assertDatabaseMissing('care_audit_logs', ['action' => 'appointment.meet_cancelled']); + } + protected function createAppointment(string $status = Appointment::STATUS_SCHEDULED): Appointment { return Appointment::create([