Gate Queue to doctors and land specialty modules on list indexes.
Deploy Ladill Care / deploy (push) Successful in 34s

Hide the patient-flow Queue board from nurses, reception, and other
non-doctors; specialty home routes now render the queue board list
instead of auto-opening the first open visit.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-18 22:53:12 +00:00
co-authored by Cursor
parent 244bb8fba6
commit d422797050
22 changed files with 277 additions and 122 deletions
+11 -2
View File
@@ -55,6 +55,7 @@ class CareAppointmentTest extends TestCase
'organization_id' => $this->organization->id,
'user_ref' => $this->user->public_id,
'role' => 'receptionist',
'branch_id' => null,
]);
$this->branch = Branch::create([
@@ -64,6 +65,10 @@ class CareAppointmentTest extends TestCase
'is_active' => true,
]);
Member::query()->where('user_ref', $this->user->public_id)->update([
'branch_id' => $this->branch->id,
]);
Department::create([
'owner_ref' => $this->user->public_id,
'branch_id' => $this->branch->id,
@@ -135,7 +140,7 @@ class CareAppointmentTest extends TestCase
$this->actingAs($this->user)
->post(route('care.appointments.check-in', $appointment))
->assertRedirect(route('care.queue.index'));
->assertRedirect(route('care.appointments.show', $appointment));
$appointment->refresh();
$this->assertSame(Appointment::STATUS_WAITING, $appointment->status);
@@ -201,7 +206,7 @@ class CareAppointmentTest extends TestCase
'practitioner_id' => $this->practitioner->id,
'reason' => 'Urgent visit',
])
->assertRedirect(route('care.queue.index'));
->assertRedirect(route('care.appointments.show', Appointment::query()->first()));
$appointment = Appointment::first();
$this->assertSame(Appointment::TYPE_WALK_IN, $appointment->type);
@@ -211,6 +216,8 @@ class CareAppointmentTest extends TestCase
public function test_queue_page_loads(): void
{
Member::query()->where('user_ref', $this->user->public_id)->update(['role' => 'doctor']);
$this->actingAs($this->user)
->get(route('care.queue.index', ['branch_id' => $this->branch->id]))
->assertOk()
@@ -219,6 +226,8 @@ class CareAppointmentTest extends TestCase
public function test_queue_repairs_duplicate_positions(): void
{
Member::query()->where('user_ref', $this->user->public_id)->update(['role' => 'doctor']);
$second = Patient::create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'owner_ref' => $this->user->public_id,
@@ -218,7 +218,7 @@ class CareConsultationReturnContextTest extends TestCase
$this->actingAs($this->user)
->withSession([ConsultationReturnContext::SESSION_KEY => $this->consultation->uuid])
->post(route('care.consultations.complete', $this->consultation))
->assertRedirect(route('care.queue.index'))
->assertRedirect(route('care.appointments.index'))
->assertSessionMissing(ConsultationReturnContext::SESSION_KEY);
}
}
+13
View File
@@ -58,6 +58,7 @@ class CareNaturalQueueTest extends TestCase
'organization_id' => $this->organization->id,
'user_ref' => $this->owner->public_id,
'role' => 'receptionist',
'branch_id' => null,
]);
$this->branch = Branch::create([
@@ -66,6 +67,10 @@ class CareNaturalQueueTest extends TestCase
'name' => 'Main',
'is_active' => true,
]);
Member::query()->where('user_ref', $this->owner->public_id)->update([
'branch_id' => $this->branch->id,
]);
}
public function test_sidebar_does_not_show_service_queues_nav(): void
@@ -78,6 +83,8 @@ class CareNaturalQueueTest extends TestCase
public function test_service_queues_index_redirects_to_clinical_queue(): void
{
Member::query()->where('user_ref', $this->owner->public_id)->update(['role' => 'doctor']);
$this->actingAs($this->owner)
->get(route('care.service-queues.index'))
->assertRedirect(route('care.queue.index'));
@@ -85,6 +92,8 @@ class CareNaturalQueueTest extends TestCase
public function test_clinical_queue_shows_inline_ops_not_service_counter(): void
{
Member::query()->where('user_ref', $this->owner->public_id)->update(['role' => 'doctor']);
$this->actingAs($this->owner)
->get(route('care.queue.index'))
->assertOk()
@@ -119,6 +128,8 @@ class CareNaturalQueueTest extends TestCase
public function test_integration_off_hides_queue_controls(): void
{
Member::query()->where('user_ref', $this->owner->public_id)->update(['role' => 'doctor']);
$settings = $this->organization->settings;
$settings['queue_integration_enabled'] = false;
$this->organization->update(['settings' => $settings]);
@@ -134,6 +145,8 @@ class CareNaturalQueueTest extends TestCase
{
Http::fake();
Member::query()->where('user_ref', $this->owner->public_id)->update(['role' => 'doctor']);
$practitioner = Practitioner::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
+112 -14
View File
@@ -3,11 +3,16 @@
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\User;
use App\Models\Visit;
use App\Services\Care\CarePermissions;
use App\Services\Care\SpecialtyModuleService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@@ -100,15 +105,36 @@ class CarePatientQueueAccessTest extends TestCase
->assertDontSee('href="'.e(route('care.queue.index')).'"', false);
}
public function test_doctor_and_receptionist_can_open_redesigned_queue_board(): void
public function test_receptionist_and_other_non_doctors_cannot_open_patient_queue(): void
{
[$receptionistUser, $receptionistMember] = $this->makeStaff('receptionist', 'rec1');
[$pharmacistUser, $pharmacistMember] = $this->makeStaff('pharmacist', 'rx1');
$permissions = app(CarePermissions::class);
$this->assertFalse($permissions->canAccessPatientQueue($receptionistMember));
$this->assertFalse($permissions->canAccessPatientQueue($pharmacistMember));
$this->assertTrue($permissions->can($receptionistMember, 'queue.manage'));
$this->actingAs($receptionistUser)
->get(route('care.queue.index'))
->assertForbidden();
$this->actingAs($receptionistUser)
->get(route('care.dashboard'))
->assertOk()
->assertDontSee('href="'.e(route('care.queue.index')).'"', false);
$this->actingAs($pharmacistUser)
->get(route('care.queue.index'))
->assertForbidden();
}
public function test_doctor_can_open_redesigned_queue_board(): void
{
[$doctorUser, $doctorMember] = $this->makeStaff('doctor', 'doc1');
[$receptionistUser, $receptionistMember] = $this->makeStaff('receptionist', 'rec1');
$permissions = app(CarePermissions::class);
$this->assertTrue($permissions->canAccessPatientQueue($doctorMember));
$this->assertTrue($permissions->canAccessPatientQueue($receptionistMember));
$this->assertTrue($permissions->can($receptionistMember, 'queue.manage'));
$this->actingAs($doctorUser)
->get(route('care.queue.index'))
@@ -118,29 +144,101 @@ class CarePatientQueueAccessTest extends TestCase
->assertSee('Called')
->assertSee('Done');
$this->actingAs($receptionistUser)
->get(route('care.queue.index'))
->assertOk()
->assertSee('Patient flow')
->assertSee('Call next');
$this->actingAs($doctorUser)
->get(route('care.dashboard'))
->assertOk()
->assertSee('Queue', false);
}
public function test_nurse_specialty_show_redirects_to_workspace_not_queue(): void
public function test_hospital_admin_does_not_get_patient_queue_board(): void
{
$adminMember = Member::query()
->where('user_ref', $this->owner->public_id)
->firstOrFail();
$permissions = app(CarePermissions::class);
$this->assertFalse($permissions->canAccessPatientQueue($adminMember));
$this->assertFalse($permissions->handlesFloorCare($adminMember));
$this->actingAs($this->owner)
->get(route('care.queue.index'))
->assertForbidden();
}
public function test_nurse_specialty_show_renders_list_not_auto_open_visit(): void
{
[$nurseUser] = $this->makeStaff('nurse', 'nurse-spec');
app(\App\Services\Care\SpecialtyModuleService::class)
$modules = app(SpecialtyModuleService::class);
$modules->ensureDefaultModulesProvisioned($this->organization, $this->owner->public_id);
$modules->activate($this->organization->fresh(), $this->owner->public_id, 'blood_bank');
$department = Department::query()
->where('branch_id', $this->branch->id)
->where('type', 'blood_bank')
->firstOrFail();
$patient = Patient::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_number' => 'P-BB-LIST',
'first_name' => 'Kwame',
'last_name' => 'Donor',
'gender' => 'male',
'date_of_birth' => '1988-01-01',
]);
$visit = Visit::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $patient->id,
'status' => Visit::STATUS_OPEN,
'checked_in_at' => now(),
]);
Appointment::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $patient->id,
'department_id' => $department->id,
'visit_id' => $visit->id,
'type' => Appointment::TYPE_WALK_IN,
'status' => Appointment::STATUS_WAITING,
'scheduled_at' => now(),
'waiting_at' => now(),
'checked_in_at' => now(),
]);
$this->actingAs($nurseUser)
->get(route('care.specialty.show', 'blood_bank'))
->assertOk()
->assertSee('Blood Bank')
->assertSee('Patient flow')
->assertSee('Waiting')
->assertSee('Kwame Donor')
->assertDontSee('No open visit selected');
$this->actingAs($nurseUser)
->get(route('care.specialty.workspace', 'blood_bank'))
->assertRedirect(route('care.specialty.show', 'blood_bank'));
}
public function test_nurse_specialty_show_redirects_to_workspace_not_queue(): void
{
[$nurseUser] = $this->makeStaff('nurse', 'nurse-spec-er');
app(SpecialtyModuleService::class)
->ensureDefaultModulesProvisioned($this->organization, $this->owner->public_id);
app(\App\Services\Care\SpecialtyModuleService::class)
app(SpecialtyModuleService::class)
->activate($this->organization->fresh(), $this->owner->public_id, 'emergency');
$this->actingAs($nurseUser)
->get(route('care.specialty.show', 'emergency'))
->assertRedirect(route('care.specialty.workspace', 'emergency'));
->assertOk()
->assertSee('Patient flow')
->assertDontSee('href="'.e(route('care.queue.index')).'"', false);
}
}
+46 -9
View File
@@ -78,6 +78,29 @@ class CareQueueBridgeTest extends TestCase
]);
}
/**
* Patient-flow board is doctor-only; reception keeps check-in / walk-in helpers.
*
* @return array{0: User, 1: Member}
*/
protected function makeDoctor(string $suffix = 'doc'): array
{
$user = User::create([
'public_id' => 'bridge-'.$suffix,
'name' => 'Bridge Doctor '.$suffix,
'email' => $suffix.'@bridge.example.com',
]);
$member = Member::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $user->public_id,
'role' => 'doctor',
'branch_id' => $this->branch->id,
]);
return [$user, $member];
}
public function test_check_in_issues_consultation_ticket_when_integration_on(): void
{
Http::fake();
@@ -164,7 +187,9 @@ class CareQueueBridgeTest extends TestCase
'queue_ticket_status' => 'waiting',
]);
$this->actingAs($this->owner)
[$doctor] = $this->makeDoctor('wait');
$this->actingAs($doctor)
->get(route('care.queue.index'))
->assertOk()
->assertSee('C042')
@@ -198,7 +223,9 @@ class CareQueueBridgeTest extends TestCase
'queue_position' => 1,
]);
$response = $this->actingAs($this->owner)
[$doctor] = $this->makeDoctor('backfill');
$response = $this->actingAs($doctor)
->from(route('care.queue.index'))
->post(route('care.queue.call-next'), [
'branch_id' => $this->branch->id,
@@ -221,7 +248,9 @@ class CareQueueBridgeTest extends TestCase
{
Http::fake();
$this->actingAs($this->owner)
[$doctor] = $this->makeDoctor('empty');
$this->actingAs($doctor)
->from(route('care.queue.index'))
->post(route('care.queue.call-next'), ['branch_id' => $this->branch->id])
->assertRedirect(route('care.queue.index'))
@@ -304,7 +333,9 @@ class CareQueueBridgeTest extends TestCase
$this->assertSame('called', $appointment->fresh()->queue_ticket_status);
$this->actingAs($this->owner)
[$viewer] = $this->makeDoctor('recall-view');
$this->actingAs($viewer)
->get(route('care.queue.index', ['branch_id' => $this->branch->id]))
->assertOk()
->assertSee($appointment->queue_ticket_number);
@@ -368,7 +399,9 @@ class CareQueueBridgeTest extends TestCase
]);
// Board GET must remain read-only; ticket backfill is explicit (Call next / artisan).
$this->actingAs($this->owner)
[$doctor] = $this->makeDoctor('sync');
$this->actingAs($doctor)
->get(route('care.queue.index', ['branch_id' => $this->branch->id]))
->assertOk()
->assertSee('Toothache');
@@ -484,7 +517,9 @@ class CareQueueBridgeTest extends TestCase
$next->fresh(['patient', 'practitioner', 'organization']),
);
$response = $this->actingAs($this->owner)
[$doctor] = $this->makeDoctor('advance');
$response = $this->actingAs($doctor)
->post(route('care.queue.call-next'), [
'branch_id' => $this->branch->id,
'practitioner_id' => $practitioner->id,
@@ -499,7 +534,7 @@ class CareQueueBridgeTest extends TestCase
$this->assertSame('called', $next->queue_ticket_status);
$response->assertRedirect(route('care.appointments.show', $next));
$this->actingAs($this->owner)
$this->actingAs($doctor)
->get(route('care.appointments.show', $next))
->assertOk()
->assertSee('Call again');
@@ -675,7 +710,9 @@ class CareQueueBridgeTest extends TestCase
'care_entity_uuid' => $appointment->uuid,
]);
$this->actingAs($this->owner)
[$caller] = $this->makeDoctor('reclaim');
$this->actingAs($caller)
->from(route('care.queue.index'))
->post(route('care.queue.call-next'), [
'branch_id' => $this->branch->id,
@@ -700,7 +737,7 @@ class CareQueueBridgeTest extends TestCase
'status' => 'pending',
]);
$this->actingAs($this->owner)
$this->actingAs($caller)
->get(route('care.queue.index', [
'branch_id' => $this->branch->id,
'practitioner_id' => $doctor->id,
+1 -1
View File
@@ -278,7 +278,7 @@ class CareSpecialtyAccessTest extends TestCase
'branch_id' => $this->branch->id,
'reason' => 'Chest pain review',
])
->assertRedirect(route('care.queue.index'));
->assertRedirect(route('care.specialty.show', 'cardiology'));
$this->assertDatabaseHas('care_appointments', [
'patient_id' => $patient->id,
+12 -6
View File
@@ -231,11 +231,13 @@ class CareSpecialtyModulesTest extends TestCase
$this->actingAs($this->owner)
->get(route('care.specialty.show', 'dentistry'))
->assertRedirect(route('care.specialty.workspace', 'dentistry'));
->assertOk()
->assertSee('Patient flow');
$this->actingAs($nurse)
->get(route('care.specialty.show', 'dentistry'))
->assertRedirect(route('care.specialty.workspace', 'dentistry'));
->assertOk()
->assertSee('Patient flow');
}
public function test_unassigned_gp_can_open_limited_specialty_module(): void
@@ -258,7 +260,8 @@ class CareSpecialtyModulesTest extends TestCase
$this->actingAs($doctor)
->get(route('care.specialty.show', 'dentistry'))
->assertRedirect(route('care.queue.index'));
->assertOk()
->assertSee('Patient flow');
}
public function test_assigned_specialty_doctor_can_open_module(): void
@@ -293,7 +296,8 @@ class CareSpecialtyModulesTest extends TestCase
$this->actingAs($doctor)
->get(route('care.specialty.show', 'dentistry'))
->assertRedirect(route('care.queue.index'));
->assertOk()
->assertSee('Patient flow');
$this->assertTrue(
app(SpecialtyModuleService::class)->memberCanAccess(
@@ -365,11 +369,13 @@ class CareSpecialtyModulesTest extends TestCase
$this->actingAs($this->owner)
->get(route('care.specialty.show', 'emergency'))
->assertRedirect(route('care.specialty.workspace', 'emergency'));
->assertOk()
->assertSee('Patient flow');
$this->actingAs($nurse)
->get(route('care.specialty.show', 'emergency'))
->assertRedirect(route('care.specialty.workspace', 'emergency'));
->assertOk()
->assertSee('Patient flow');
$this->expectException(\RuntimeException::class);
$service->deactivate($this->organization->fresh(), $this->owner->public_id, 'emergency');
+6 -5
View File
@@ -96,11 +96,14 @@ class CareSpecialtyShellTest extends TestCase
$this->actingAs($this->owner)
->get(route('care.specialty.show', 'emergency'))
->assertRedirect(route('care.specialty.workspace', 'emergency'));
->assertOk()
->assertSee('Patient flow')
->assertSee('Waiting');
$this->actingAs($this->owner)
->get(route('care.specialty.visits', 'emergency'))
->assertRedirect(route('care.specialty.workspace', 'emergency'));
->assertOk()
->assertSee('Patient flow');
$this->actingAs($this->owner)
->get(route('care.specialty.history', 'emergency'))
@@ -115,9 +118,7 @@ class CareSpecialtyShellTest extends TestCase
$this->actingAs($this->owner)
->get(route('care.specialty.workspace', 'emergency'))
->assertOk()
->assertSee('No open visit selected')
->assertDontSee('Back to queue');
->assertRedirect(route('care.specialty.show', 'emergency'));
}
public function test_dentistry_shell_has_chair_stage_and_catalog(): void