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
+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
{
return array_values(array_filter(
$this->enabledModules($organization),
fn (array $item) => $this->memberCanAccess($organization, $member, $item['key']),
));
$out = [];
foreach ($this->enabledModules($organization) as $item) {
$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.
* Doctors must be linked to a practitioner desk in that specialty department.
* Receptionists keep queue/ops visibility; facility admins do not staff specialty floors.
* @return 'general'|'limited'|'restricted'
*/
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
{
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;
}
@@ -163,25 +268,36 @@ class SpecialtyModuleService
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;
}
$roles = $definition['roles'] ?? [];
if (is_array($roles) && $roles !== [] && $member && ! in_array($member->role, $roles, true)) {
if (! $this->isEnabled($organization, $key) || ! $member) {
return false;
}
if ($member && $member->role === 'doctor') {
// Default Pro surfaces (Emergency, Blood Bank) are available to all doctors.
if ($this->isDefaultOnPaidPlans($key)) {
return true;
}
return $this->doctorAssignedToModule($organization, $member, $key);
$definition = $this->definition($key);
if (! $definition) {
return false;
}
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
@@ -201,9 +317,77 @@ class SpecialtyModuleService
? 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);
$practitioners = Practitioner::owned((string) $organization->owner_ref)
return Practitioner::owned((string) $organization->owner_ref)
->where('organization_id', $organization->id)
->where('is_active', true)
->where(function ($query) use ($member) {
@@ -219,17 +403,6 @@ class SpecialtyModuleService
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