Gate specialty modules by staff role with view and refer levels.
Deploy Ladill Care / deploy (push) Successful in 37s

Give nurses, lab techs, pharmacists, and GPs broad access to day-to-day modules, keep restricted specialties for specialists (with support nurses), and let GPs view results or refer without full clinical edit.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-18 18:03:47 +00:00
co-authored by Cursor
parent 7989ab9184
commit 2cff8fdd6c
13 changed files with 762 additions and 89 deletions
@@ -37,6 +37,12 @@ class DentistryWorkspaceController extends Controller
abort_unless($modules->memberCanAccess($organization, $this->member($request), 'dentistry'), 403); abort_unless($modules->memberCanAccess($organization, $this->member($request), 'dentistry'), 403);
} }
protected function assertDentistryManage(Request $request, SpecialtyModuleService $modules): void
{
$organization = $this->organization($request);
abort_unless($modules->memberCanManage($organization, $this->member($request), 'dentistry'), 403);
}
protected function assertVisit(Request $request, Visit $visit): void protected function assertVisit(Request $request, Visit $visit): void
{ {
abort_unless($visit->organization_id === $this->organization($request)->id, 404); abort_unless($visit->organization_id === $this->organization($request)->id, 404);
@@ -50,7 +56,7 @@ class DentistryWorkspaceController extends Controller
DentalChartService $charts, DentalChartService $charts,
): RedirectResponse { ): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage'); $this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules); $this->assertDentistryManage($request, $modules);
$this->assertVisit($request, $visit); $this->assertVisit($request, $visit);
$validated = $request->validate([ $validated = $request->validate([
@@ -93,7 +99,7 @@ class DentistryWorkspaceController extends Controller
DentalTreatmentPlanService $plans, DentalTreatmentPlanService $plans,
): RedirectResponse { ): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage'); $this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules); $this->assertDentistryManage($request, $modules);
$this->assertVisit($request, $visit); $this->assertVisit($request, $visit);
$validated = $request->validate([ $validated = $request->validate([
@@ -128,7 +134,7 @@ class DentistryWorkspaceController extends Controller
DentalTreatmentPlanService $plans, DentalTreatmentPlanService $plans,
): RedirectResponse { ): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage'); $this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules); $this->assertDentistryManage($request, $modules);
$this->assertVisit($request, $visit); $this->assertVisit($request, $visit);
$organization = $this->organization($request); $organization = $this->organization($request);
@@ -153,7 +159,7 @@ class DentistryWorkspaceController extends Controller
DentalProcedureService $procedures, DentalProcedureService $procedures,
): RedirectResponse { ): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage'); $this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules); $this->assertDentistryManage($request, $modules);
$this->assertVisit($request, $visit); $this->assertVisit($request, $visit);
$validated = $request->validate([ $validated = $request->validate([
@@ -194,7 +200,7 @@ class DentistryWorkspaceController extends Controller
DentalVisitNoteService $notes, DentalVisitNoteService $notes,
): RedirectResponse { ): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage'); $this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules); $this->assertDentistryManage($request, $modules);
$this->assertVisit($request, $visit); $this->assertVisit($request, $visit);
$validated = $request->validate([ $validated = $request->validate([
@@ -225,7 +231,7 @@ class DentistryWorkspaceController extends Controller
DentalProcedureService $procedures, DentalProcedureService $procedures,
): RedirectResponse { ): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage'); $this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules); $this->assertDentistryManage($request, $modules);
$this->assertVisit($request, $visit); $this->assertVisit($request, $visit);
$validated = $request->validate([ $validated = $request->validate([
@@ -335,7 +341,7 @@ class DentistryWorkspaceController extends Controller
DentalChartService $charts, DentalChartService $charts,
): RedirectResponse { ): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage'); $this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules); $this->assertDentistryManage($request, $modules);
$this->assertVisit($request, $visit); $this->assertVisit($request, $visit);
$validated = $request->validate([ $validated = $request->validate([
@@ -371,7 +377,7 @@ class DentistryWorkspaceController extends Controller
DentalTreatmentPlanService $plans, DentalTreatmentPlanService $plans,
): RedirectResponse { ): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage'); $this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules); $this->assertDentistryManage($request, $modules);
$this->assertVisit($request, $visit); $this->assertVisit($request, $visit);
$validated = $request->validate([ $validated = $request->validate([
@@ -408,7 +414,7 @@ class DentistryWorkspaceController extends Controller
DentalTreatmentPlanService $plans, DentalTreatmentPlanService $plans,
): RedirectResponse { ): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage'); $this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules); $this->assertDentistryManage($request, $modules);
$this->assertVisit($request, $visit); $this->assertVisit($request, $visit);
$organization = $this->organization($request); $organization = $this->organization($request);
@@ -435,7 +441,7 @@ class DentistryWorkspaceController extends Controller
DentalTreatmentPlanService $plans, DentalTreatmentPlanService $plans,
): RedirectResponse { ): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage'); $this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules); $this->assertDentistryManage($request, $modules);
$this->assertVisit($request, $visit); $this->assertVisit($request, $visit);
$validated = $request->validate([ $validated = $request->validate([
@@ -473,7 +479,7 @@ class DentistryWorkspaceController extends Controller
DentalTreatmentPlanService $plans, DentalTreatmentPlanService $plans,
): RedirectResponse { ): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage'); $this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules); $this->assertDentistryManage($request, $modules);
$this->assertVisit($request, $visit); $this->assertVisit($request, $visit);
$organization = $this->organization($request); $organization = $this->organization($request);
@@ -503,7 +509,7 @@ class DentistryWorkspaceController extends Controller
DentalImagingService $imaging, DentalImagingService $imaging,
): RedirectResponse { ): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage'); $this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules); $this->assertDentistryManage($request, $modules);
$this->assertVisit($request, $visit); $this->assertVisit($request, $visit);
$organization = $this->organization($request); $organization = $this->organization($request);
@@ -532,7 +538,7 @@ class DentistryWorkspaceController extends Controller
SpecialtyVisitStageService $stages, SpecialtyVisitStageService $stages,
): RedirectResponse { ): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage'); $this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules); $this->assertDentistryManage($request, $modules);
$this->assertVisit($request, $visit); $this->assertVisit($request, $visit);
$validated = $request->validate([ $validated = $request->validate([
@@ -564,7 +570,7 @@ class DentistryWorkspaceController extends Controller
DentalPerioService $perio, DentalPerioService $perio,
): RedirectResponse { ): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage'); $this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules); $this->assertDentistryManage($request, $modules);
$this->assertVisit($request, $visit); $this->assertVisit($request, $visit);
$validated = $request->validate([ $validated = $request->validate([
@@ -608,7 +614,7 @@ class DentistryWorkspaceController extends Controller
DentalLabCaseService $labCases, DentalLabCaseService $labCases,
): RedirectResponse { ): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage'); $this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules); $this->assertDentistryManage($request, $modules);
$this->assertVisit($request, $visit); $this->assertVisit($request, $visit);
$validated = $request->validate([ $validated = $request->validate([
@@ -651,7 +657,7 @@ class DentistryWorkspaceController extends Controller
DentalLabCaseService $labCases, DentalLabCaseService $labCases,
): RedirectResponse { ): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage'); $this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules); $this->assertDentistryManage($request, $modules);
$this->assertVisit($request, $visit); $this->assertVisit($request, $visit);
$validated = $request->validate([ $validated = $request->validate([
@@ -689,7 +695,7 @@ class DentistryWorkspaceController extends Controller
DentalRecallService $recalls, DentalRecallService $recalls,
): RedirectResponse { ): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage'); $this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules); $this->assertDentistryManage($request, $modules);
$this->assertVisit($request, $visit); $this->assertVisit($request, $visit);
$validated = $request->validate([ $validated = $request->validate([
@@ -727,7 +733,7 @@ class DentistryWorkspaceController extends Controller
DentalRecallService $recalls, DentalRecallService $recalls,
): RedirectResponse { ): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage'); $this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules); $this->assertDentistryManage($request, $modules);
$this->assertVisit($request, $visit); $this->assertVisit($request, $visit);
$organization = $this->organization($request); $organization = $this->organization($request);
@@ -762,7 +768,7 @@ class DentistryWorkspaceController extends Controller
DentalRecallService $recalls, DentalRecallService $recalls,
): RedirectResponse { ): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage'); $this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules); $this->assertDentistryManage($request, $modules);
$this->assertVisit($request, $visit); $this->assertVisit($request, $visit);
$organization = $this->organization($request); $organization = $this->organization($request);
@@ -27,6 +27,12 @@ class EmergencyWorkspaceController extends Controller
abort_unless($modules->memberCanAccess($organization, $this->member($request), 'emergency'), 403); abort_unless($modules->memberCanAccess($organization, $this->member($request), 'emergency'), 403);
} }
protected function assertEmergencyManage(Request $request, SpecialtyModuleService $modules): void
{
$organization = $this->organization($request);
abort_unless($modules->memberCanManage($organization, $this->member($request), 'emergency'), 403);
}
protected function assertVisit(Request $request, Visit $visit): void protected function assertVisit(Request $request, Visit $visit): void
{ {
abort_unless($visit->organization_id === $this->organization($request)->id, 404); abort_unless($visit->organization_id === $this->organization($request)->id, 404);
@@ -40,7 +46,7 @@ class EmergencyWorkspaceController extends Controller
SpecialtyVisitStageService $stages, SpecialtyVisitStageService $stages,
): RedirectResponse { ): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage'); $this->authorizeAbility($request, 'consultations.manage');
$this->assertEmergencyAccess($request, $modules); $this->assertEmergencyManage($request, $modules);
$this->assertVisit($request, $visit); $this->assertVisit($request, $visit);
$validated = $request->validate([ $validated = $request->validate([
@@ -72,7 +78,7 @@ class EmergencyWorkspaceController extends Controller
EmergencyVitalsService $vitals, EmergencyVitalsService $vitals,
): RedirectResponse { ): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage'); $this->authorizeAbility($request, 'consultations.manage');
$this->assertEmergencyAccess($request, $modules); $this->assertEmergencyManage($request, $modules);
$this->assertVisit($request, $visit); $this->assertVisit($request, $visit);
$validated = $request->validate([ $validated = $request->validate([
@@ -112,7 +118,7 @@ class EmergencyWorkspaceController extends Controller
EmergencyWorkflowService $workflow, EmergencyWorkflowService $workflow,
): RedirectResponse { ): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage'); $this->authorizeAbility($request, 'consultations.manage');
$this->assertEmergencyAccess($request, $modules); $this->assertEmergencyManage($request, $modules);
$this->assertVisit($request, $visit); $this->assertVisit($request, $visit);
$organization = $this->organization($request); $organization = $this->organization($request);
@@ -104,6 +104,7 @@ class SpecialtyModuleController extends Controller
$organization = $this->organization($request); $organization = $this->organization($request);
$member = $this->member($request); $member = $this->member($request);
abort_unless($modules->memberCanAccess($organization, $member, $module), 403); abort_unless($modules->memberCanAccess($organization, $member, $module), 403);
abort_unless($modules->memberCanManage($organization, $member, $module), 403);
abort_unless($visit->organization_id === $organization->id, 404); abort_unless($visit->organization_id === $organization->id, 404);
$validated = $request->validate([ $validated = $request->validate([
@@ -142,6 +143,7 @@ class SpecialtyModuleController extends Controller
$organization = $this->organization($request); $organization = $this->organization($request);
$member = $this->member($request); $member = $this->member($request);
abort_unless($modules->memberCanAccess($organization, $member, $module), 403); abort_unless($modules->memberCanAccess($organization, $member, $module), 403);
abort_unless($modules->memberCanManage($organization, $member, $module), 403);
abort_unless($visit->organization_id === $organization->id, 404); abort_unless($visit->organization_id === $organization->id, 404);
$this->authorizeBranch($request, (int) $visit->branch_id); $this->authorizeBranch($request, (int) $visit->branch_id);
@@ -256,6 +258,7 @@ class SpecialtyModuleController extends Controller
$organization = $this->organization($request); $organization = $this->organization($request);
$member = $this->member($request); $member = $this->member($request);
abort_unless($modules->memberCanAccess($organization, $member, $module), 403); abort_unless($modules->memberCanAccess($organization, $member, $module), 403);
abort_unless($modules->memberCanManage($organization, $member, $module), 403);
abort_unless($visit->organization_id === $organization->id, 404); abort_unless($visit->organization_id === $organization->id, 404);
$tab = (string) $request->input('tab', ''); $tab = (string) $request->input('tab', '');
@@ -345,6 +348,7 @@ class SpecialtyModuleController extends Controller
$organization = $this->organization($request); $organization = $this->organization($request);
$member = $this->member($request); $member = $this->member($request);
abort_unless($modules->memberCanAccess($organization, $member, $module), 403); abort_unless($modules->memberCanAccess($organization, $member, $module), 403);
abort_unless($modules->memberCanManage($organization, $member, $module), 403);
abort_unless($visit->organization_id === $organization->id, 404); abort_unless($visit->organization_id === $organization->id, 404);
abort_unless($visit->patient_id, 422); abort_unless($visit->patient_id, 422);
@@ -388,6 +392,7 @@ class SpecialtyModuleController extends Controller
$member = $this->member($request); $member = $this->member($request);
$owner = $this->ownerRef($request); $owner = $this->ownerRef($request);
abort_unless($modules->memberCanAccess($organization, $member, $module), 403); abort_unless($modules->memberCanAccess($organization, $member, $module), 403);
abort_unless($modules->memberCanManage($organization, $member, $module), 403);
abort_unless($queueBridge->isEnabled($organization), 404); abort_unless($queueBridge->isEnabled($organization), 404);
$resolver = app(OrganizationResolver::class); $resolver = app(OrganizationResolver::class);
@@ -473,6 +478,74 @@ class SpecialtyModuleController extends Controller
return back()->with('success', 'Called '.$ticket['ticket_number'].' — '.$name.'.'); return back()->with('success', 'Called '.$ticket['ticket_number'].' — '.$name.'.');
} }
/**
* Refer a patient into this specialty queue (walk-in) without full clinical edit access.
*/
public function refer(
Request $request,
string $module,
SpecialtyModuleService $modules,
AppointmentService $appointments,
): RedirectResponse {
$definition = $modules->definition($module);
abort_unless($definition, 404);
$organization = $this->organization($request);
$member = $this->member($request);
abort_unless($modules->memberCanRefer($organization, $member, $module), 403);
$owner = $this->ownerRef($request);
$branchScope = app(OrganizationResolver::class)->branchScope($member);
$validated = $request->validate([
'patient_id' => ['required', 'integer', 'exists:care_patients,id'],
'branch_id' => ['nullable', 'integer', 'exists:care_branches,id'],
'reason' => ['nullable', 'string', 'max:500'],
'notes' => ['nullable', 'string', 'max:2000'],
]);
$branchId = (int) ($validated['branch_id'] ?? $branchScope ?? 0);
abort_unless($branchId > 0, 422);
$this->authorizeBranch($request, $branchId);
$department = Department::owned($owner)
->where('type', $definition['department_type'] ?? 'general')
->where('branch_id', $branchId)
->where('is_active', true)
->orderBy('id')
->first();
if (! $department) {
return back()->with('error', 'No active '.($definition['label'] ?? $module).' department at this branch.');
}
$patient = \App\Models\Patient::owned($owner)
->where('organization_id', $organization->id)
->findOrFail((int) $validated['patient_id']);
$appointment = $appointments->walkIn($organization, $owner, [
'patient_id' => $patient->id,
'branch_id' => $branchId,
'department_id' => $department->id,
'reason' => $validated['reason'] ?? ('Referral to '.($definition['label'] ?? $module)),
'notes' => $validated['notes'] ?? null,
], $owner);
\App\Services\Care\AuditLogger::record(
$owner,
'specialty.referral',
$organization->id,
$owner,
Appointment::class,
$appointment->id,
['module' => $module, 'department_id' => $department->id],
);
return redirect()
->route('care.queue.index')
->with('success', 'Referred '.$patient->fullName().' to '.($definition['label'] ?? $module).'.');
}
protected function renderShell( protected function renderShell(
Request $request, Request $request,
string $module, string $module,
@@ -489,6 +562,10 @@ class SpecialtyModuleController extends Controller
$member = $this->member($request); $member = $this->member($request);
abort_unless($modules->memberCanAccess($organization, $member, $module), 403); abort_unless($modules->memberCanAccess($organization, $member, $module), 403);
$canManageSpecialty = $modules->memberCanManage($organization, $member, $module);
$canReferSpecialty = $modules->memberCanRefer($organization, $member, $module);
$specialtyAccessLevel = $modules->memberAccessLevel($organization, $member, $module);
$owner = $this->ownerRef($request); $owner = $this->ownerRef($request);
$resolver = app(OrganizationResolver::class); $resolver = app(OrganizationResolver::class);
$branchScope = $resolver->branchScope($member); $branchScope = $resolver->branchScope($member);
@@ -559,8 +636,8 @@ class SpecialtyModuleController extends Controller
$services = $shell->provisionedServices($organization, $module); $services = $shell->provisionedServices($organization, $module);
$permissions = app(\App\Services\Care\CarePermissions::class); $permissions = app(\App\Services\Care\CarePermissions::class);
$canConsult = $permissions->can($member, 'consultations.manage'); $canConsult = $permissions->can($member, 'consultations.manage') && $canManageSpecialty;
$canManageQueue = $permissions->can($member, 'appointments.manage'); $canManageQueue = $permissions->can($member, 'appointments.manage') && $canManageSpecialty;
$branchLabel = $branchId $branchLabel = $branchId
? (string) (\App\Models\Branch::owned($owner)->whereKey($branchId)->value('name') ?? '') ? (string) (\App\Models\Branch::owned($owner)->whereKey($branchId)->value('name') ?? '')
: ''; : '';
@@ -803,6 +880,9 @@ class SpecialtyModuleController extends Controller
'lockToPractitioner' => $practitionerScope !== null, 'lockToPractitioner' => $practitionerScope !== null,
'canConsult' => $canConsult, 'canConsult' => $canConsult,
'canManageQueue' => $canManageQueue, 'canManageQueue' => $canManageQueue,
'canManageSpecialty' => $canManageSpecialty,
'canReferSpecialty' => $canReferSpecialty,
'specialtyAccessLevel' => $specialtyAccessLevel,
'queueIntegration' => $branchId 'queueIntegration' => $branchId
? $queueBridge->listMeta($organization, $module, $branchId) ? $queueBridge->listMeta($organization, $module, $branchId)
: ['enabled' => false], : ['enabled' => false],
+206 -33
View File
@@ -135,26 +135,131 @@ class SpecialtyModuleService
} }
/** /**
* Enabled modules the member may open (role allowlist + doctor specialty assignment). * Enabled modules the member may open (manage, view, or refer).
* *
* @return list<array{key: string, definition: array<string, mixed>}> * @return list<array{key: string, definition: array<string, mixed>, access_level: string}>
*/ */
public function enabledModulesForMember(Organization $organization, ?Member $member): array public function enabledModulesForMember(Organization $organization, ?Member $member): array
{ {
return array_values(array_filter( $out = [];
$this->enabledModules($organization), foreach ($this->enabledModules($organization) as $item) {
fn (array $item) => $this->memberCanAccess($organization, $member, $item['key']), $level = $this->memberAccessLevel($organization, $member, $item['key']);
)); if ($level === 'none') {
continue;
}
$out[] = array_merge($item, ['access_level' => $level]);
}
return $out;
} }
/** /**
* Whether the member may see/serve this specialty module. * @return 'general'|'limited'|'restricted'
* Doctors must be linked to a practitioner desk in that specialty department. */
* Receptionists keep queue/ops visibility; facility admins do not staff specialty floors. public function accessTier(string $key): string
{
$tier = (string) ($this->definition($key)['access'] ?? 'general');
return in_array($tier, ['general', 'limited', 'restricted'], true) ? $tier : 'general';
}
/**
* Highest access for this member on the module.
*
* @return 'none'|'view'|'refer'|'manage'
*/
public function memberAccessLevel(Organization $organization, ?Member $member, string $key): string
{
if (! $this->isEnabled($organization, $key)) {
return 'none';
}
$definition = $this->definition($key);
if (! $definition || ! $member) {
return 'none';
}
if (app(CarePermissions::class)->isAdmin($member)) {
return 'manage';
}
if ($this->memberCanManage($organization, $member, $key)) {
return 'manage';
}
$canRefer = $this->roleListed($member, $definition['refer_roles'] ?? []);
$canView = $this->roleListed($member, $definition['view_roles'] ?? []);
if ($canRefer && $canView) {
return 'refer';
}
if ($canRefer) {
return 'refer';
}
if ($canView) {
return 'view';
}
return 'none';
}
/**
* Whether the member may see this specialty module in nav / open workspace (any level).
*/ */
public function memberCanAccess(Organization $organization, ?Member $member, string $key): bool public function memberCanAccess(Organization $organization, ?Member $member, string $key): bool
{ {
if (! $this->isEnabled($organization, $key)) { return $this->memberAccessLevel($organization, $member, $key) !== 'none';
}
/**
* Full clinical / queue edit access for the module.
*/
public function memberCanManage(Organization $organization, ?Member $member, string $key): bool
{
if (! $this->isEnabled($organization, $key) || ! $member) {
return false;
}
if (app(CarePermissions::class)->isAdmin($member)) {
return true;
}
$definition = $this->definition($key);
if (! $definition) {
return false;
}
$tier = $this->accessTier($key);
$role = (string) $member->role;
$manageRoles = $definition['roles'] ?? [];
$supportRoles = $definition['support_roles'] ?? [];
if ($tier === 'restricted') {
if ($this->roleListed($member, $supportRoles)) {
return true;
}
if ($role === 'doctor' && $this->roleListed($member, $manageRoles)) {
return $this->doctorAssignedToModule($organization, $member, $key)
|| $this->doctorSpecialtyMatchesModule($organization, $member, $key);
}
return false;
}
// general + limited: role allowlist is enough (GPs = doctor without specialty desk).
return $this->roleListed($member, $manageRoles);
}
/**
* Read results / history / workspace without clinical edit.
*/
public function memberCanView(Organization $organization, ?Member $member, string $key): bool
{
if ($this->memberCanManage($organization, $member, $key)) {
return true;
}
if (! $this->isEnabled($organization, $key) || ! $member) {
return false; return false;
} }
@@ -163,25 +268,36 @@ class SpecialtyModuleService
return false; return false;
} }
if (app(CarePermissions::class)->isAdmin($member)) { return $this->roleListed($member, $definition['view_roles'] ?? []);
}
/**
* Refer a patient into the specialty queue without full clinical edit.
*/
public function memberCanRefer(Organization $organization, ?Member $member, string $key): bool
{
if ($this->memberCanManage($organization, $member, $key)) {
return true; return true;
} }
$roles = $definition['roles'] ?? []; if (! $this->isEnabled($organization, $key) || ! $member) {
if (is_array($roles) && $roles !== [] && $member && ! in_array($member->role, $roles, true)) {
return false; return false;
} }
if ($member && $member->role === 'doctor') { $definition = $this->definition($key);
// Default Pro surfaces (Emergency, Blood Bank) are available to all doctors. if (! $definition) {
if ($this->isDefaultOnPaidPlans($key)) { return false;
return true;
}
return $this->doctorAssignedToModule($organization, $member, $key);
} }
return true; return $this->roleListed($member, $definition['refer_roles'] ?? []);
}
/**
* @param list<string>|mixed $roles
*/
protected function roleListed(Member $member, mixed $roles): bool
{
return is_array($roles) && $roles !== [] && in_array($member->role, $roles, true);
} }
public function doctorAssignedToModule(Organization $organization, Member $member, string $key): bool public function doctorAssignedToModule(Organization $organization, Member $member, string $key): bool
@@ -201,9 +317,77 @@ class SpecialtyModuleService
? array_map('intval', $provisionedIds) ? array_map('intval', $provisionedIds)
: []; : [];
foreach ($this->practitionersForMember($organization, $member) as $practitioner) {
if ($provisionedIds !== [] && in_array((int) $practitioner->department_id, $provisionedIds, true)) {
return true;
}
if ($departmentType !== '' && $practitioner->department?->type === $departmentType) {
return true;
}
}
return false;
}
/**
* Match practitioner.specialty text to module specialist_keywords (restricted specialists).
*/
public function doctorSpecialtyMatchesModule(Organization $organization, Member $member, string $key): bool
{
$definition = $this->definition($key);
if (! $definition) {
return false;
}
$keywords = $definition['specialist_keywords'] ?? [];
if (! is_array($keywords) || $keywords === []) {
$keywords = $definition['queue_keywords'] ?? [];
}
if (! is_array($keywords) || $keywords === []) {
return false;
}
foreach ($this->practitionersForMember($organization, $member) as $practitioner) {
$hay = strtolower(trim((string) ($practitioner->specialty ?? '')));
if ($hay === '' || $this->isGeneralPracticeSpecialty($hay)) {
continue;
}
foreach ($keywords as $keyword) {
$needle = strtolower((string) $keyword);
if ($needle !== '' && str_contains($hay, $needle)) {
return true;
}
}
}
return false;
}
protected function isGeneralPracticeSpecialty(string $specialtyLower): bool
{
if (in_array($specialtyLower, [
'general practice',
'general',
'gp',
'family medicine',
'internal medicine',
'nursing',
], true)) {
return true;
}
return str_contains($specialtyLower, 'general practice')
|| str_contains($specialtyLower, 'family medicine');
}
/**
* @return \Illuminate\Support\Collection<int, Practitioner>
*/
protected function practitionersForMember(Organization $organization, Member $member)
{
$branchId = app(OrganizationResolver::class)->branchScope($member); $branchId = app(OrganizationResolver::class)->branchScope($member);
$practitioners = Practitioner::owned((string) $organization->owner_ref) return Practitioner::owned((string) $organization->owner_ref)
->where('organization_id', $organization->id) ->where('organization_id', $organization->id)
->where('is_active', true) ->where('is_active', true)
->where(function ($query) use ($member) { ->where(function ($query) use ($member) {
@@ -219,17 +403,6 @@ class SpecialtyModuleService
return in_array((int) $branchId, $practitioner->assignedBranchIds(), true); return in_array((int) $branchId, $practitioner->assignedBranchIds(), true);
}); });
foreach ($practitioners as $practitioner) {
if ($provisionedIds !== [] && in_array((int) $practitioner->department_id, $provisionedIds, true)) {
return true;
}
if ($departmentType !== '' && $practitioner->department?->type === $departmentType) {
return true;
}
}
return false;
} }
public function canManage(Organization $organization): bool public function canManage(Organization $organization): bool
+87 -16
View File
@@ -4,6 +4,12 @@
* Specialty practice modules (Settings Modules). * Specialty practice modules (Settings Modules).
* Pro-gated via plans.*.features specialty_modules. * Pro-gated via plans.*.features specialty_modules.
* *
* Access tiers (RBAC via Care member roles do not invent new roles):
* - general: broad day-to-day access for listed roles (GPs = doctor)
* - limited: GPs + nurses (and listed roles) without specialist assignment
* - restricted: specialist doctors (department / specialty keywords) + optional support_roles;
* other clinicians use view_roles / refer_roles
*
* default_on_paid_plans: always on for Pro/Enterprise (sidebar + access). * default_on_paid_plans: always on for Pro/Enterprise (sidebar + access).
* *
* @return array<string, array<string, mixed>> * @return array<string, array<string, mixed>>
@@ -18,7 +24,8 @@ return [
'queue_prefix' => 'ER', 'queue_prefix' => 'ER',
'nav_label' => 'Emergency', 'nav_label' => 'Emergency',
'queue_keywords' => ['emerg', 'casualty', 'trauma', 'er', 'a&e'], 'queue_keywords' => ['emerg', 'casualty', 'trauma', 'er', 'a&e'],
'roles' => ['doctor', 'nurse', 'receptionist'], 'access' => 'general',
'roles' => ['doctor', 'nurse', 'lab_technician', 'pharmacist', 'receptionist'],
'default_on_paid_plans' => true, 'default_on_paid_plans' => true,
], ],
'blood_bank' => [ 'blood_bank' => [
@@ -30,7 +37,8 @@ return [
'queue_prefix' => 'BB', 'queue_prefix' => 'BB',
'nav_label' => 'Blood Bank', 'nav_label' => 'Blood Bank',
'queue_keywords' => ['blood', 'transfusion', 'crossmatch', 'donor'], 'queue_keywords' => ['blood', 'transfusion', 'crossmatch', 'donor'],
'roles' => ['doctor', 'nurse', 'lab_technician', 'receptionist'], 'access' => 'general',
'roles' => ['lab_technician', 'nurse', 'doctor', 'receptionist'],
'default_on_paid_plans' => true, 'default_on_paid_plans' => true,
], ],
'dentistry' => [ 'dentistry' => [
@@ -42,7 +50,9 @@ return [
'queue_prefix' => 'DEN', 'queue_prefix' => 'DEN',
'nav_label' => 'Dentistry', 'nav_label' => 'Dentistry',
'queue_keywords' => ['dent', 'dental', 'oral'], 'queue_keywords' => ['dent', 'dental', 'oral'],
'access' => 'limited',
'roles' => ['doctor', 'nurse', 'receptionist'], 'roles' => ['doctor', 'nurse', 'receptionist'],
'specialist_keywords' => ['dent', 'dental', 'oral', 'odont'],
], ],
'ophthalmology' => [ 'ophthalmology' => [
'label' => 'Eye care', 'label' => 'Eye care',
@@ -53,7 +63,9 @@ return [
'queue_prefix' => 'EYE', 'queue_prefix' => 'EYE',
'nav_label' => 'Eye care', 'nav_label' => 'Eye care',
'queue_keywords' => ['eye', 'ophthal', 'optom'], 'queue_keywords' => ['eye', 'ophthal', 'optom'],
'access' => 'limited',
'roles' => ['doctor', 'nurse', 'receptionist'], 'roles' => ['doctor', 'nurse', 'receptionist'],
'specialist_keywords' => ['eye', 'ophthal', 'optom'],
], ],
'physiotherapy' => [ 'physiotherapy' => [
'label' => 'Physiotherapy', 'label' => 'Physiotherapy',
@@ -64,6 +76,7 @@ return [
'queue_prefix' => 'PHY', 'queue_prefix' => 'PHY',
'nav_label' => 'Physiotherapy', 'nav_label' => 'Physiotherapy',
'queue_keywords' => ['physio', 'rehab', 'therapy'], 'queue_keywords' => ['physio', 'rehab', 'therapy'],
'access' => 'general',
'roles' => ['doctor', 'nurse', 'receptionist'], 'roles' => ['doctor', 'nurse', 'receptionist'],
], ],
'maternity' => [ 'maternity' => [
@@ -75,7 +88,8 @@ return [
'queue_prefix' => 'MAT', 'queue_prefix' => 'MAT',
'nav_label' => 'Maternity', 'nav_label' => 'Maternity',
'queue_keywords' => ['matern', 'antenatal', 'obstetric'], 'queue_keywords' => ['matern', 'antenatal', 'obstetric'],
'roles' => ['doctor', 'nurse', 'receptionist'], 'access' => 'general',
'roles' => ['doctor', 'nurse', 'pharmacist', 'receptionist'],
], ],
'radiology' => [ 'radiology' => [
'label' => 'Radiology', 'label' => 'Radiology',
@@ -86,7 +100,8 @@ return [
'queue_prefix' => 'RAD', 'queue_prefix' => 'RAD',
'nav_label' => 'Radiology', 'nav_label' => 'Radiology',
'queue_keywords' => ['radio', 'imaging', 'x-ray', 'xray', 'ultrasound', 'ct', 'mri'], 'queue_keywords' => ['radio', 'imaging', 'x-ray', 'xray', 'ultrasound', 'ct', 'mri'],
'roles' => ['doctor', 'lab_technician', 'receptionist'], 'access' => 'general',
'roles' => ['doctor', 'nurse', 'lab_technician', 'receptionist'],
], ],
'cardiology' => [ 'cardiology' => [
'label' => 'Cardiology', 'label' => 'Cardiology',
@@ -97,7 +112,12 @@ return [
'queue_prefix' => 'CAR', 'queue_prefix' => 'CAR',
'nav_label' => 'Cardiology', 'nav_label' => 'Cardiology',
'queue_keywords' => ['cardio', 'heart', 'ecg', 'echo'], 'queue_keywords' => ['cardio', 'heart', 'ecg', 'echo'],
'roles' => ['doctor', 'nurse', 'receptionist'], 'access' => 'restricted',
'roles' => ['doctor'],
'support_roles' => ['nurse'],
'view_roles' => ['doctor', 'nurse', 'lab_technician'],
'refer_roles' => ['doctor', 'nurse'],
'specialist_keywords' => ['cardio', 'cardiology', 'heart'],
], ],
'psychiatry' => [ 'psychiatry' => [
'label' => 'Psychiatry', 'label' => 'Psychiatry',
@@ -108,7 +128,12 @@ return [
'queue_prefix' => 'PSY', 'queue_prefix' => 'PSY',
'nav_label' => 'Psychiatry', 'nav_label' => 'Psychiatry',
'queue_keywords' => ['psych', 'mental', 'behaviour'], 'queue_keywords' => ['psych', 'mental', 'behaviour'],
'roles' => ['doctor', 'nurse', 'receptionist'], 'access' => 'restricted',
'roles' => ['doctor'],
'support_roles' => ['nurse'],
'view_roles' => ['doctor', 'nurse'],
'refer_roles' => ['doctor', 'nurse'],
'specialist_keywords' => ['psych', 'psychiatry', 'mental'],
], ],
'pediatrics' => [ 'pediatrics' => [
'label' => 'Pediatrics', 'label' => 'Pediatrics',
@@ -119,6 +144,7 @@ return [
'queue_prefix' => 'PED', 'queue_prefix' => 'PED',
'nav_label' => 'Pediatrics', 'nav_label' => 'Pediatrics',
'queue_keywords' => ['pedia', 'paedia', 'child', 'infant'], 'queue_keywords' => ['pedia', 'paedia', 'child', 'infant'],
'access' => 'general',
'roles' => ['doctor', 'nurse', 'receptionist'], 'roles' => ['doctor', 'nurse', 'receptionist'],
], ],
'orthopedics' => [ 'orthopedics' => [
@@ -130,7 +156,12 @@ return [
'queue_prefix' => 'ORT', 'queue_prefix' => 'ORT',
'nav_label' => 'Orthopedics', 'nav_label' => 'Orthopedics',
'queue_keywords' => ['ortho', 'fracture', 'bone', 'joint'], 'queue_keywords' => ['ortho', 'fracture', 'bone', 'joint'],
'roles' => ['doctor', 'nurse', 'receptionist'], 'access' => 'restricted',
'roles' => ['doctor'],
'support_roles' => [],
'view_roles' => ['doctor', 'nurse'],
'refer_roles' => ['doctor', 'nurse'],
'specialist_keywords' => ['ortho', 'orthopedic', 'orthopaedic', 'fracture'],
], ],
'ent' => [ 'ent' => [
'label' => 'ENT', 'label' => 'ENT',
@@ -141,7 +172,12 @@ return [
'queue_prefix' => 'ENT', 'queue_prefix' => 'ENT',
'nav_label' => 'ENT', 'nav_label' => 'ENT',
'queue_keywords' => ['ent', 'ear', 'nose', 'throat', 'otolaryng'], 'queue_keywords' => ['ent', 'ear', 'nose', 'throat', 'otolaryng'],
'roles' => ['doctor', 'nurse', 'receptionist'], 'access' => 'restricted',
'roles' => ['doctor'],
'support_roles' => [],
'view_roles' => ['doctor', 'nurse'],
'refer_roles' => ['doctor', 'nurse'],
'specialist_keywords' => ['ent', 'otolaryng', 'ear', 'nose', 'throat'],
], ],
'oncology' => [ 'oncology' => [
'label' => 'Oncology', 'label' => 'Oncology',
@@ -152,7 +188,12 @@ return [
'queue_prefix' => 'ONC', 'queue_prefix' => 'ONC',
'nav_label' => 'Oncology', 'nav_label' => 'Oncology',
'queue_keywords' => ['oncol', 'cancer', 'chemo'], 'queue_keywords' => ['oncol', 'cancer', 'chemo'],
'roles' => ['doctor', 'nurse', 'receptionist'], 'access' => 'restricted',
'roles' => ['doctor'],
'support_roles' => ['nurse'],
'view_roles' => ['doctor', 'nurse', 'pharmacist'],
'refer_roles' => ['doctor', 'nurse'],
'specialist_keywords' => ['oncol', 'cancer', 'chemo'],
], ],
'renal' => [ 'renal' => [
'label' => 'Renal Care', 'label' => 'Renal Care',
@@ -163,7 +204,12 @@ return [
'queue_prefix' => 'REN', 'queue_prefix' => 'REN',
'nav_label' => 'Renal Care', 'nav_label' => 'Renal Care',
'queue_keywords' => ['renal', 'dialysis', 'nephro', 'kidney'], 'queue_keywords' => ['renal', 'dialysis', 'nephro', 'kidney'],
'roles' => ['doctor', 'nurse', 'receptionist'], 'access' => 'restricted',
'roles' => ['doctor'],
'support_roles' => ['nurse'],
'view_roles' => ['doctor', 'nurse'],
'refer_roles' => ['doctor', 'nurse'],
'specialist_keywords' => ['renal', 'nephro', 'dialysis', 'kidney'],
], ],
'surgery' => [ 'surgery' => [
'label' => 'Surgery', 'label' => 'Surgery',
@@ -174,7 +220,12 @@ return [
'queue_prefix' => 'SUR', 'queue_prefix' => 'SUR',
'nav_label' => 'Surgery', 'nav_label' => 'Surgery',
'queue_keywords' => ['surg', 'theatre', 'operat', 'pre-op'], 'queue_keywords' => ['surg', 'theatre', 'operat', 'pre-op'],
'roles' => ['doctor', 'nurse', 'receptionist'], 'access' => 'restricted',
'roles' => ['doctor'],
'support_roles' => ['nurse'],
'view_roles' => ['doctor', 'nurse'],
'refer_roles' => ['doctor', 'nurse'],
'specialist_keywords' => ['surg', 'theatre', 'operat'],
], ],
'vaccination' => [ 'vaccination' => [
'label' => 'Vaccination & Immunization', 'label' => 'Vaccination & Immunization',
@@ -185,6 +236,7 @@ return [
'queue_prefix' => 'VAX', 'queue_prefix' => 'VAX',
'nav_label' => 'Vaccination', 'nav_label' => 'Vaccination',
'queue_keywords' => ['vaccin', 'immun', 'epi', 'injection'], 'queue_keywords' => ['vaccin', 'immun', 'epi', 'injection'],
'access' => 'general',
'roles' => ['doctor', 'nurse', 'receptionist'], 'roles' => ['doctor', 'nurse', 'receptionist'],
], ],
'pathology' => [ 'pathology' => [
@@ -196,7 +248,9 @@ return [
'queue_prefix' => 'PATH', 'queue_prefix' => 'PATH',
'nav_label' => 'Pathology', 'nav_label' => 'Pathology',
'queue_keywords' => ['pathol', 'histo', 'cytol', 'biopsy'], 'queue_keywords' => ['pathol', 'histo', 'cytol', 'biopsy'],
'roles' => ['doctor', 'lab_technician', 'receptionist'], 'access' => 'general',
'roles' => ['lab_technician', 'doctor', 'nurse', 'receptionist'],
'specialist_keywords' => ['pathol', 'histo', 'cytol'],
], ],
'infusion' => [ 'infusion' => [
'label' => 'Infusion Centre', 'label' => 'Infusion Centre',
@@ -207,7 +261,8 @@ return [
'queue_prefix' => 'INF', 'queue_prefix' => 'INF',
'nav_label' => 'Infusion', 'nav_label' => 'Infusion',
'queue_keywords' => ['infusion', 'iv', 'drip', 'day care'], 'queue_keywords' => ['infusion', 'iv', 'drip', 'day care'],
'roles' => ['doctor', 'nurse', 'receptionist'], 'access' => 'general',
'roles' => ['doctor', 'nurse', 'pharmacist', 'receptionist'],
], ],
'dermatology' => [ 'dermatology' => [
'label' => 'Dermatology', 'label' => 'Dermatology',
@@ -218,7 +273,12 @@ return [
'queue_prefix' => 'DER', 'queue_prefix' => 'DER',
'nav_label' => 'Dermatology', 'nav_label' => 'Dermatology',
'queue_keywords' => ['derma', 'skin', 'rash'], 'queue_keywords' => ['derma', 'skin', 'rash'],
'roles' => ['doctor', 'nurse', 'receptionist'], 'access' => 'restricted',
'roles' => ['doctor'],
'support_roles' => [],
'view_roles' => ['doctor', 'nurse'],
'refer_roles' => ['doctor', 'nurse'],
'specialist_keywords' => ['derma', 'skin'],
], ],
'podiatry' => [ 'podiatry' => [
'label' => 'Podiatry', 'label' => 'Podiatry',
@@ -229,7 +289,12 @@ return [
'queue_prefix' => 'POD', 'queue_prefix' => 'POD',
'nav_label' => 'Podiatry', 'nav_label' => 'Podiatry',
'queue_keywords' => ['podiat', 'foot', 'diabetic foot'], 'queue_keywords' => ['podiat', 'foot', 'diabetic foot'],
'roles' => ['doctor', 'nurse', 'receptionist'], 'access' => 'restricted',
'roles' => ['doctor'],
'support_roles' => [],
'view_roles' => ['doctor', 'nurse'],
'refer_roles' => ['doctor', 'nurse'],
'specialist_keywords' => ['podiat', 'foot'],
], ],
'fertility' => [ 'fertility' => [
'label' => 'Fertility', 'label' => 'Fertility',
@@ -240,7 +305,12 @@ return [
'queue_prefix' => 'FER', 'queue_prefix' => 'FER',
'nav_label' => 'Fertility', 'nav_label' => 'Fertility',
'queue_keywords' => ['fertil', 'ivf', 'reproduct'], 'queue_keywords' => ['fertil', 'ivf', 'reproduct'],
'roles' => ['doctor', 'nurse', 'receptionist'], 'access' => 'restricted',
'roles' => ['doctor'],
'support_roles' => [],
'view_roles' => ['doctor', 'nurse'],
'refer_roles' => ['doctor', 'nurse'],
'specialist_keywords' => ['fertil', 'ivf', 'reproduct'],
], ],
'child_welfare' => [ 'child_welfare' => [
'label' => 'Child Welfare Clinic', 'label' => 'Child Welfare Clinic',
@@ -251,6 +321,7 @@ return [
'queue_prefix' => 'CWC', 'queue_prefix' => 'CWC',
'nav_label' => 'Child Welfare', 'nav_label' => 'Child Welfare',
'queue_keywords' => ['cwc', 'child welfare', 'well baby', 'growth'], 'queue_keywords' => ['cwc', 'child welfare', 'well baby', 'growth'],
'access' => 'general',
'roles' => ['doctor', 'nurse', 'receptionist'], 'roles' => ['doctor', 'nurse', 'receptionist'],
], ],
]; ];
+8 -1
View File
@@ -205,7 +205,14 @@
@endphp @endphp
<a href="{{ $href }}" <a href="{{ $href }}"
class="rounded-2xl border border-slate-200 bg-white px-4 py-4 transition hover:border-indigo-300 hover:shadow-sm"> class="rounded-2xl border border-slate-200 bg-white px-4 py-4 transition hover:border-indigo-300 hover:shadow-sm">
<p class="text-sm font-semibold text-slate-900">{{ $item['definition']['nav_label'] ?? $item['definition']['label'] }}</p> <div class="flex items-start justify-between gap-2">
<p class="text-sm font-semibold text-slate-900">{{ $item['definition']['nav_label'] ?? $item['definition']['label'] }}</p>
@if (! empty($item['access_level']) && $item['access_level'] !== 'manage' && ! $toSettings)
<span class="shrink-0 rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-800">
{{ $item['access_level'] === 'refer' ? 'View + refer' : 'View' }}
</span>
@endif
</div>
<p class="mt-1 text-xs text-slate-500">{{ $item['definition']['description'] ?? '' }}</p> <p class="mt-1 text-xs text-slate-500">{{ $item['definition']['description'] ?? '' }}</p>
@if ($toSettings) @if ($toSettings)
<p class="mt-2 text-[11px] font-medium text-indigo-600">Settings &amp; pricing </p> <p class="mt-2 text-[11px] font-medium text-indigo-600">Settings &amp; pricing </p>
@@ -58,25 +58,25 @@
@endif @endif
@if ($workspaceVisit) @if ($workspaceVisit)
@if ($moduleKey === 'dentistry' && $currentStage && isset($stageFlow[$currentStage])) @if (($canManageSpecialty ?? true) && $moduleKey === 'dentistry' && $currentStage && isset($stageFlow[$currentStage]))
<form method="POST" action="{{ route('care.specialty.dentistry.stage', $workspaceVisit) }}" class="w-full" @click.stop> <form method="POST" action="{{ route('care.specialty.dentistry.stage', $workspaceVisit) }}" class="w-full" @click.stop>
@csrf @csrf
<input type="hidden" name="stage" value="{{ $stageFlow[$currentStage]['next'] }}"> <input type="hidden" name="stage" value="{{ $stageFlow[$currentStage]['next'] }}">
<button type="submit" class="{{ $btnAccent }}">{{ $stageFlow[$currentStage]['label'] }}</button> <button type="submit" class="{{ $btnAccent }}">{{ $stageFlow[$currentStage]['label'] }}</button>
</form> </form>
@elseif ($moduleKey === 'dentistry' && ! $currentStage) @elseif (($canManageSpecialty ?? true) && $moduleKey === 'dentistry' && ! $currentStage)
<form method="POST" action="{{ route('care.specialty.dentistry.stage', $workspaceVisit) }}" class="w-full" @click.stop> <form method="POST" action="{{ route('care.specialty.dentistry.stage', $workspaceVisit) }}" class="w-full" @click.stop>
@csrf @csrf
<input type="hidden" name="stage" value="chair"> <input type="hidden" name="stage" value="chair">
<button type="submit" class="{{ $btnAccent }}">Seat at chair</button> <button type="submit" class="{{ $btnAccent }}">Seat at chair</button>
</form> </form>
@elseif ($moduleKey === 'emergency' && $currentStage && isset($stageFlow[$currentStage]) && ($stageFlow[$currentStage]['next'] ?? null) !== $currentStage) @elseif (($canManageSpecialty ?? true) && $moduleKey === 'emergency' && $currentStage && isset($stageFlow[$currentStage]) && ($stageFlow[$currentStage]['next'] ?? null) !== $currentStage)
<form method="POST" action="{{ route('care.specialty.emergency.stage', $workspaceVisit) }}" class="w-full" @click.stop> <form method="POST" action="{{ route('care.specialty.emergency.stage', $workspaceVisit) }}" class="w-full" @click.stop>
@csrf @csrf
<input type="hidden" name="stage" value="{{ $stageFlow[$currentStage]['next'] }}"> <input type="hidden" name="stage" value="{{ $stageFlow[$currentStage]['next'] }}">
<button type="submit" class="{{ $btnAccent }}">{{ $stageFlow[$currentStage]['label'] }}</button> <button type="submit" class="{{ $btnAccent }}">{{ $stageFlow[$currentStage]['label'] }}</button>
</form> </form>
@elseif ($moduleKey === 'emergency' && ! $currentStage) @elseif (($canManageSpecialty ?? true) && $moduleKey === 'emergency' && ! $currentStage)
<form method="POST" action="{{ route('care.specialty.emergency.stage', $workspaceVisit) }}" class="w-full" @click.stop> <form method="POST" action="{{ route('care.specialty.emergency.stage', $workspaceVisit) }}" class="w-full" @click.stop>
@csrf @csrf
<input type="hidden" name="stage" value="arrival"> <input type="hidden" name="stage" value="arrival">
@@ -84,7 +84,7 @@
</form> </form>
@endif @endif
@if ($canCompleteConsultation) @if (($canManageSpecialty ?? true) && $canCompleteConsultation)
<button <button
type="button" type="button"
class="{{ $btnPrimary }}" class="{{ $btnPrimary }}"
@@ -99,7 +99,7 @@
class="{{ $btnSecondary }}"> class="{{ $btnSecondary }}">
Open chart Open chart
</a> </a>
@elseif ($canStartConsultation) @elseif (($canManageSpecialty ?? true) && $canStartConsultation)
<form method="POST" action="{{ route('care.specialty.consultation.start', ['module' => $moduleKey, 'visit' => $workspaceVisit]) }}" class="w-full" @click.stop> <form method="POST" action="{{ route('care.specialty.consultation.start', ['module' => $moduleKey, 'visit' => $workspaceVisit]) }}" class="w-full" @click.stop>
@csrf @csrf
<button type="submit" class="{{ $btnPrimary }}"> <button type="submit" class="{{ $btnPrimary }}">
@@ -107,6 +107,15 @@
</button> </button>
</form> </form>
@endif @endif
@if (($canReferSpecialty ?? false) && ! ($canManageSpecialty ?? true) && $workspaceVisit->patient)
<form method="POST" action="{{ route('care.specialty.refer', $moduleKey) }}" class="w-full space-y-2" @click.stop>
@csrf
<input type="hidden" name="patient_id" value="{{ $workspaceVisit->patient->id }}">
<input type="hidden" name="branch_id" value="{{ $workspaceVisit->branch_id }}">
<input type="text" name="reason" placeholder="Referral reason" class="w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs">
<button type="submit" class="{{ $btnAccent }}">Refer to specialty queue</button>
</form>
@endif
@if ($workspaceVisit->patient) @if ($workspaceVisit->patient)
<a href="{{ route('care.patients.show', $workspaceVisit->patient) }}" class="{{ $btnSecondary }}"> <a href="{{ route('care.patients.show', $workspaceVisit->patient) }}" class="{{ $btnSecondary }}">
Patient chart Patient chart
@@ -29,6 +29,25 @@
</div> </div>
@endif @endif
@if (! ($canManageSpecialty ?? true))
<dl class="mt-4 grid gap-3 text-sm sm:grid-cols-2">
@forelse ($clinicalFields as $field)
@php
$name = $field['name'];
$value = $values[$name] ?? null;
if (is_bool($value)) {
$value = $value ? 'Yes' : 'No';
}
@endphp
<div @class(['sm:col-span-2' => ($field['type'] ?? '') === 'textarea'])>
<dt class="text-slate-500">{{ $field['label'] }}</dt>
<dd class="mt-0.5 font-medium text-slate-900 whitespace-pre-wrap">{{ $value !== null && $value !== '' ? $value : '—' }}</dd>
</div>
@empty
<p class="text-slate-500">No clinical record yet.</p>
@endforelse
</dl>
@else
<form method="POST" action="{{ route('care.specialty.clinical.save', ['module' => $moduleKey, 'visit' => $workspaceVisit]) }}" class="mt-4 space-y-3"> <form method="POST" action="{{ route('care.specialty.clinical.save', ['module' => $moduleKey, 'visit' => $workspaceVisit]) }}" class="mt-4 space-y-3">
@csrf @csrf
<input type="hidden" name="tab" value="{{ $activeTab }}"> <input type="hidden" name="tab" value="{{ $activeTab }}">
@@ -103,4 +122,5 @@
@endif @endif
</div> </div>
</form> </form>
@endif
</section> </section>
+16 -1
View File
@@ -16,11 +16,26 @@
@if ($moduleKey === 'dentistry') @if ($moduleKey === 'dentistry')
<a href="{{ route('care.specialty.dentistry.reports') }}" class="text-slate-600 hover:text-slate-900">Reports</a> <a href="{{ route('care.specialty.dentistry.reports') }}" class="text-slate-600 hover:text-slate-900">Reports</a>
@endif @endif
@if ($moduleKey === 'emergency')
<a href="{{ route('care.specialty.emergency.reports') }}" class="text-slate-600 hover:text-slate-900">Reports</a>
@endif
<a href="{{ route('care.specialty.history', $moduleKey) }}" class="text-slate-600 hover:text-slate-900">History</a> <a href="{{ route('care.specialty.history', $moduleKey) }}" class="text-slate-600 hover:text-slate-900">History</a>
<a href="{{ route('care.specialty.billing', $moduleKey) }}" class="text-slate-600 hover:text-slate-900">Billing</a> @if ($canManageSpecialty ?? true)
<a href="{{ route('care.specialty.billing', $moduleKey) }}" class="text-slate-600 hover:text-slate-900">Billing</a>
@endif
</div> </div>
</div> </div>
@if (($specialtyAccessLevel ?? 'manage') !== 'manage')
<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
@if (($specialtyAccessLevel ?? '') === 'refer')
You have <strong>view + refer</strong> access. You can review results and refer patients into this specialty queue; clinical editing is limited to specialists and supporting staff.
@else
You have <strong>view-only</strong> access. Results and history are available; clinical edits require specialty access.
@endif
</div>
@endif
<div class="lg:grid lg:grid-cols-[minmax(0,1fr)_16rem] lg:items-start lg:gap-6"> <div class="lg:grid lg:grid-cols-[minmax(0,1fr)_16rem] lg:items-start lg:gap-6">
<div class="min-w-0 space-y-4"> <div class="min-w-0 space-y-4">
@include('care.specialty.sections.workspace') @include('care.specialty.sections.workspace')
+20 -2
View File
@@ -76,12 +76,25 @@
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />']; 'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />'];
} }
// Specialty modules stay enabled for clinical depth, but patient flow lives on Queue only. // Specialty modules — role-gated clinical surfaces for floor staff.
if ($permissions->handlesFloorCare($member) && $organization) { if ($permissions->handlesFloorCare($member) && $organization) {
app(\App\Services\Care\SpecialtyModuleService::class)->ensureDefaultModulesProvisioned( $specialtyService = app(\App\Services\Care\SpecialtyModuleService::class);
$specialtyService->ensureDefaultModulesProvisioned(
$organization, $organization,
(string) $organization->owner_ref, (string) $organization->owner_ref,
); );
$specialtyIcon = '<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714c0 .846.344 1.657.955 2.247L19 14.5M14.25 3.104c.251.023.501.05.75.082M19 14.5l-1.659 1.659a2.25 2.25 0 0 1-1.591.659H8.25" />';
foreach ($specialtyService->enabledModulesForMember($organization, $member) as $item) {
$key = $item['key'];
$label = $item['definition']['nav_label'] ?? $item['definition']['label'] ?? $key;
$nav[] = [
'name' => $label,
'route' => route('care.specialty.workspace', $key),
'active' => request()->routeIs('care.specialty.*') && request()->route('module') === $key,
'icon' => $specialtyIcon,
'group' => 'specialty',
];
}
} }
} }
@@ -108,7 +121,12 @@
@endphp @endphp
<nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4"> <nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
@php $specialtyHeadingShown = false; @endphp
@foreach ($nav as $item) @foreach ($nav as $item)
@if (($item['group'] ?? null) === 'specialty' && ! $specialtyHeadingShown)
<p class="mb-1 mt-4 px-3 text-[10px] font-semibold uppercase tracking-wider text-slate-400">Specialty</p>
@php $specialtyHeadingShown = true; @endphp
@endif
<a href="{{ $item['route'] }}" class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ $item['active'] ? 'bg-indigo-50 text-indigo-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}"> <a href="{{ $item['route'] }}" class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ $item['active'] ? 'bg-indigo-50 text-indigo-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
<svg class="{{ $item['iconSizeClass'] ?? 'h-[18px] w-[18px]' }} shrink-0 {{ $item['active'] ? 'text-indigo-600' : 'text-slate-400' }}" viewBox="{{ $item['iconViewBox'] ?? '0 0 24 24' }}" fill="none" stroke="currentColor" stroke-width="{{ $item['iconStrokeWidth'] ?? 1.5 }}">{!! $item['icon'] !!}</svg> <svg class="{{ $item['iconSizeClass'] ?? 'h-[18px] w-[18px]' }} shrink-0 {{ $item['active'] ? 'text-indigo-600' : 'text-slate-400' }}" viewBox="{{ $item['iconViewBox'] ?? '0 0 24 24' }}" fill="none" stroke="currentColor" stroke-width="{{ $item['iconStrokeWidth'] ?? 1.5 }}">{!! $item['icon'] !!}</svg>
<span>{{ $item['name'] }}</span> <span>{{ $item['name'] }}</span>
+1
View File
@@ -147,6 +147,7 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::get('/specialty/emergency/workspace/{visit}/print', [EmergencyWorkspaceController::class, 'printSummary'])->name('care.specialty.emergency.print'); Route::get('/specialty/emergency/workspace/{visit}/print', [EmergencyWorkspaceController::class, 'printSummary'])->name('care.specialty.emergency.print');
Route::get('/specialty/{module}', [SpecialtyModuleController::class, 'show'])->name('care.specialty.show'); Route::get('/specialty/{module}', [SpecialtyModuleController::class, 'show'])->name('care.specialty.show');
Route::post('/specialty/{module}/call-next', [SpecialtyModuleController::class, 'callNext'])->name('care.specialty.call-next'); Route::post('/specialty/{module}/call-next', [SpecialtyModuleController::class, 'callNext'])->name('care.specialty.call-next');
Route::post('/specialty/{module}/refer', [SpecialtyModuleController::class, 'refer'])->name('care.specialty.refer');
Route::get('/lab/requests', [InvestigationController::class, 'index'])->name('care.lab.requests.index'); Route::get('/lab/requests', [InvestigationController::class, 'index'])->name('care.lab.requests.index');
Route::get('/lab/queue', [InvestigationController::class, 'queue'])->name('care.lab.queue.index'); Route::get('/lab/queue', [InvestigationController::class, 'queue'])->name('care.lab.queue.index');
+258
View File
@@ -0,0 +1,258 @@
<?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 App\Services\Care\SpecialtyModuleService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CareSpecialtyAccessTest extends TestCase
{
use RefreshDatabase;
protected User $owner;
protected Organization $organization;
protected Branch $branch;
protected SpecialtyModuleService $modules;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
$this->owner = User::create([
'public_id' => 'access-owner',
'name' => 'Owner',
'email' => 'access-owner@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->owner->public_id,
'name' => 'Access Clinic',
'slug' => 'access-clinic',
'settings' => [
'onboarded' => true,
'facility_type' => 'clinic',
'plan' => 'pro',
'plan_expires_at' => now()->addMonth()->toIso8601String(),
'queue_integration_enabled' => true,
],
]);
Member::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $this->owner->public_id,
'role' => 'hospital_admin',
]);
$this->branch = Branch::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'name' => 'Main',
'is_active' => true,
]);
$this->modules = app(SpecialtyModuleService::class);
$this->modules->ensureDefaultModulesProvisioned($this->organization, $this->owner->public_id);
foreach (['cardiology', 'infusion', 'pathology', 'dentistry'] as $key) {
$this->modules->activate($this->organization->fresh(), $this->owner->public_id, $key);
}
$this->organization->refresh();
}
protected function makeStaff(string $role, string $suffix): array
{
$user = User::create([
'public_id' => 'access-'.$suffix,
'name' => ucfirst($role).' '.$suffix,
'email' => $suffix.'@example.com',
]);
$member = Member::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $user->public_id,
'role' => $role,
'branch_id' => $this->branch->id,
]);
return [$user, $member];
}
public function test_nurse_and_pharmacist_manage_general_modules(): void
{
[, $nurse] = $this->makeStaff('nurse', 'nurse1');
[, $pharmacist] = $this->makeStaff('pharmacist', 'rx1');
[, $lab] = $this->makeStaff('lab_technician', 'lab1');
$this->assertTrue($this->modules->memberCanManage($this->organization, $nurse, 'emergency'));
$this->assertTrue($this->modules->memberCanManage($this->organization, $pharmacist, 'emergency'));
$this->assertTrue($this->modules->memberCanManage($this->organization, $pharmacist, 'infusion'));
$this->assertTrue($this->modules->memberCanManage($this->organization, $lab, 'pathology'));
$this->assertTrue($this->modules->memberCanManage($this->organization, $lab, 'blood_bank'));
$this->assertFalse($this->modules->memberCanManage($this->organization, $pharmacist, 'cardiology'));
}
public function test_gp_doctor_manages_general_but_only_views_restricted(): void
{
[$doctor, $member] = $this->makeStaff('doctor', 'gp1');
Practitioner::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'member_id' => $member->id,
'user_ref' => $doctor->public_id,
'name' => 'Dr GP',
'specialty' => 'General Practice',
'is_active' => true,
]);
$this->assertTrue($this->modules->memberCanManage($this->organization, $member, 'emergency'));
$this->assertTrue($this->modules->memberCanManage($this->organization, $member, 'dentistry'));
$this->assertFalse($this->modules->memberCanManage($this->organization, $member, 'cardiology'));
$this->assertTrue($this->modules->memberCanView($this->organization, $member, 'cardiology'));
$this->assertTrue($this->modules->memberCanRefer($this->organization, $member, 'cardiology'));
$this->assertSame('refer', $this->modules->memberAccessLevel($this->organization, $member, 'cardiology'));
}
public function test_cardiologist_manages_restricted_cardiology(): void
{
[$doctor, $member] = $this->makeStaff('doctor', 'cardio1');
$dept = Department::query()->where('type', 'cardiology')->firstOrFail();
Practitioner::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'department_id' => $dept->id,
'member_id' => $member->id,
'user_ref' => $doctor->public_id,
'name' => 'Dr Cardio',
'specialty' => 'Cardiology',
'is_active' => true,
]);
$this->assertTrue($this->modules->memberCanManage($this->organization, $member, 'cardiology'));
$this->assertSame('manage', $this->modules->memberAccessLevel($this->organization, $member, 'cardiology'));
}
public function test_gp_can_refer_into_restricted_specialty_queue(): void
{
[$doctor, $member] = $this->makeStaff('doctor', 'gp2');
Practitioner::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'member_id' => $member->id,
'user_ref' => $doctor->public_id,
'name' => 'Dr GP2',
'specialty' => 'General Practice',
'is_active' => true,
]);
$patient = Patient::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_number' => 'P-REF-1',
'first_name' => 'Ama',
'last_name' => 'Refer',
'gender' => 'female',
'date_of_birth' => '1990-01-01',
]);
$this->actingAs($doctor)
->post(route('care.specialty.refer', 'cardiology'), [
'patient_id' => $patient->id,
'branch_id' => $this->branch->id,
'reason' => 'Chest pain review',
])
->assertRedirect(route('care.queue.index'));
$this->assertDatabaseHas('care_appointments', [
'patient_id' => $patient->id,
'reason' => 'Chest pain review',
'status' => Appointment::STATUS_WAITING,
]);
}
public function test_gp_cannot_save_clinical_on_restricted_module(): void
{
[$doctor, $member] = $this->makeStaff('doctor', 'gp3');
Practitioner::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'member_id' => $member->id,
'user_ref' => $doctor->public_id,
'name' => 'Dr GP3',
'specialty' => 'General Practice',
'is_active' => true,
]);
$dept = Department::query()->where('type', 'cardiology')->firstOrFail();
$patient = Patient::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_number' => 'P-CARD-1',
'first_name' => 'Kofi',
'last_name' => 'Heart',
'gender' => 'male',
'date_of_birth' => '1980-01-01',
]);
$visit = \App\Models\Visit::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $patient->id,
'status' => \App\Models\Visit::STATUS_IN_PROGRESS,
'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' => $dept->id,
'visit_id' => $visit->id,
'type' => Appointment::TYPE_WALK_IN,
'status' => Appointment::STATUS_IN_CONSULTATION,
'scheduled_at' => now(),
'checked_in_at' => now(),
'started_at' => now(),
]);
$this->actingAs($doctor)
->get(route('care.specialty.workspace', ['module' => 'cardiology', 'visit' => $visit]))
->assertOk()
->assertSee('view + refer');
$this->actingAs($doctor)
->post(route('care.specialty.clinical.save', ['module' => 'cardiology', 'visit' => $visit]), [
'tab' => 'exam',
'payload' => [
'chief_complaint' => 'Should not save',
],
])
->assertForbidden();
}
public function test_admin_keeps_full_manage_access(): void
{
$admin = Member::query()->where('user_ref', $this->owner->public_id)->firstOrFail();
$this->assertTrue($this->modules->memberCanManage($this->organization, $admin, 'cardiology'));
$this->assertTrue($this->modules->memberCanManage($this->organization, $admin, 'emergency'));
}
}
+15 -6
View File
@@ -231,14 +231,14 @@ class CareSpecialtyModulesTest extends TestCase
$this->actingAs($this->owner) $this->actingAs($this->owner)
->get(route('care.specialty.show', 'dentistry')) ->get(route('care.specialty.show', 'dentistry'))
->assertForbidden(); ->assertRedirect(route('care.queue.index'));
$this->actingAs($nurse) $this->actingAs($nurse)
->get(route('care.specialty.show', 'dentistry')) ->get(route('care.specialty.show', 'dentistry'))
->assertRedirect(route('care.queue.index')); ->assertRedirect(route('care.queue.index'));
} }
public function test_unassigned_doctor_cannot_open_specialty_module(): void public function test_unassigned_gp_can_open_limited_specialty_module(): void
{ {
Http::fake(); Http::fake();
app(SpecialtyModuleService::class)->activate($this->organization, $this->owner->public_id, 'dentistry'); app(SpecialtyModuleService::class)->activate($this->organization, $this->owner->public_id, 'dentistry');
@@ -258,7 +258,7 @@ class CareSpecialtyModulesTest extends TestCase
$this->actingAs($doctor) $this->actingAs($doctor)
->get(route('care.specialty.show', 'dentistry')) ->get(route('care.specialty.show', 'dentistry'))
->assertForbidden(); ->assertRedirect(route('care.queue.index'));
} }
public function test_assigned_specialty_doctor_can_open_module(): void public function test_assigned_specialty_doctor_can_open_module(): void
@@ -302,11 +302,20 @@ class CareSpecialtyModulesTest extends TestCase
'dentistry', 'dentistry',
), ),
); );
// Restricted modules stay off for GPs without specialty assignment.
$this->assertFalse( $this->assertFalse(
app(SpecialtyModuleService::class)->memberCanAccess( app(SpecialtyModuleService::class)->memberCanManage(
$this->organization->fresh(), $this->organization->fresh(),
$member, $member,
'maternity', 'cardiology',
),
);
app(SpecialtyModuleService::class)->activate($this->organization->fresh(), $this->owner->public_id, 'cardiology');
$this->assertTrue(
app(SpecialtyModuleService::class)->memberCanRefer(
$this->organization->fresh(),
$member,
'cardiology',
), ),
); );
} }
@@ -348,7 +357,7 @@ class CareSpecialtyModulesTest extends TestCase
$this->actingAs($this->owner) $this->actingAs($this->owner)
->get(route('care.specialty.show', 'emergency')) ->get(route('care.specialty.show', 'emergency'))
->assertForbidden(); ->assertRedirect(route('care.queue.index'));
$this->actingAs($nurse) $this->actingAs($nurse)
->get(route('care.specialty.show', 'emergency')) ->get(route('care.specialty.show', 'emergency'))