Gate specialty modules by staff role with view and refer levels.
Deploy Ladill Care / deploy (push) Successful in 37s
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user