Files
ladill-care/app/Services/Care/SpecialtyModuleService.php
T
isaaccladandCursor d02760c527
Deploy Ladill Care / deploy (push) Successful in 35s
Limit specialty module visibility to matching desk specialists.
GPs keep broad general/limited manage and restricted view/refer; dentists and other desk specialists only see modules that match their specialty.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 22:09:15 +00:00

758 lines
25 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services\Care;
use App\Models\Branch;
use App\Models\CareServiceQueue;
use App\Models\Department;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Practitioner;
use Illuminate\Support\Facades\Log;
/**
* Activate/deactivate specialty practice modules (dentistry, eye care, …).
*
* Persistence: organization.settings.specialty_modules[key] = bool
* Provisioning: departments per branch + Care Queue Engine stubs/points.
* Deactivate hides UI and marks queues inactive; does not destroy clinical history.
*
* Plan gate: Pro/Enterprise via PlanService feature specialty_modules (same tier as queue_integration).
*/
class SpecialtyModuleService
{
public function __construct(
protected PlanService $plans,
) {}
/**
* @return array<string, array<string, mixed>>
*/
public function catalog(): array
{
return config('care.specialty_modules', []);
}
public function definition(string $key): ?array
{
$catalog = $this->catalog();
return $catalog[$key] ?? null;
}
/**
* Heroicon-style icon identifier from the specialty catalog (e.g. bolt, heart).
*/
public function iconName(string $key): string
{
$name = (string) ($this->definition($key)['icon'] ?? 'squares-2x2');
return $name !== '' ? $name : 'squares-2x2';
}
/**
* Inline SVG path markup for sidebar / card icons (24×24 outline).
*/
public function iconSvgPath(string $key): string
{
$icons = config('care.specialty_icons', []);
$name = $this->iconName($key);
if (is_array($icons) && isset($icons[$name]) && is_string($icons[$name]) && $icons[$name] !== '') {
return $icons[$name];
}
return is_array($icons) && isset($icons['squares-2x2']) && is_string($icons['squares-2x2'])
? $icons['squares-2x2']
: '';
}
public function isDefaultOnPaidPlans(string $key): bool
{
return (bool) ($this->definition($key)['default_on_paid_plans'] ?? false);
}
public function isEnabled(Organization $organization, string $key): bool
{
if (! $this->definition($key)) {
return false;
}
if ($this->isDefaultOnPaidPlans($key) && $this->plans->hasPaidPlan($organization)) {
return true;
}
return (bool) data_get($organization->settings, "specialty_modules.{$key}", false);
}
/**
* @return list<string>
*/
public function defaultKeysForPaidPlans(): array
{
$keys = [];
foreach (array_keys($this->catalog()) as $key) {
if ($this->isDefaultOnPaidPlans($key)) {
$keys[] = $key;
}
}
return $keys;
}
/**
* Provision Emergency / Blood Bank (and any other default_on_paid_plans modules).
*/
public function ensureDefaultModulesProvisioned(Organization $organization, string $ownerRef): void
{
if (! $this->plans->hasPaidPlan($organization)) {
return;
}
foreach ($this->defaultKeysForPaidPlans() as $key) {
$provisioned = data_get(
$organization->settings,
"specialty_module_provisioning.{$key}.active",
);
if ($provisioned) {
continue;
}
try {
$this->activate($organization->fresh(), $ownerRef, $key);
$organization->refresh();
} catch (\Throwable $e) {
Log::warning('specialty_module.default_provision_failed', [
'key' => $key,
'message' => $e->getMessage(),
]);
}
}
}
/**
* @return list<string>
*/
public function enabledKeys(Organization $organization): array
{
$enabled = [];
foreach (array_keys($this->catalog()) as $key) {
if ($this->isEnabled($organization, $key)) {
$enabled[] = $key;
}
}
return $enabled;
}
/**
* @return list<array{key: string, definition: array<string, mixed>}>
*/
public function enabledModules(Organization $organization): array
{
$out = [];
foreach ($this->enabledKeys($organization) as $key) {
$definition = $this->definition($key);
if ($definition) {
$out[] = ['key' => $key, 'definition' => $definition];
}
}
return $out;
}
/**
* Enabled modules the member may open (manage, view, or refer).
*
* @return list<array{key: string, definition: array<string, mixed>, access_level: string}>
*/
public function enabledModulesForMember(Organization $organization, ?Member $member): array
{
$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;
}
/**
* @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';
}
// Use gated helpers so desk specialists do not inherit GP view/refer on other modules.
if ($this->memberCanRefer($organization, $member, $key)) {
return 'refer';
}
if ($this->memberCanView($organization, $member, $key)) {
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
{
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;
}
// Desk specialists only manage modules that match their specialty / assignment.
if ($this->isDeskSpecialist($organization, $member)
&& ! $this->specialistBelongsToModule($organization, $member, $key)) {
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->specialistBelongsToModule($organization, $member, $key);
}
return false;
}
// general + limited: role allowlist is enough for GPs and non-doctor roles.
// Desk specialists already passed the match gate above.
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;
}
// Desk specialists do not get cross-module view; only their matching desks.
if ($this->isDeskSpecialist($organization, $member)
&& ! $this->specialistBelongsToModule($organization, $member, $key)) {
return false;
}
$definition = $this->definition($key);
if (! $definition) {
return false;
}
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;
}
if (! $this->isEnabled($organization, $key) || ! $member) {
return false;
}
// Desk specialists do not get cross-module referral nav; GPs keep refer_roles.
if ($this->isDeskSpecialist($organization, $member)
&& ! $this->specialistBelongsToModule($organization, $member, $key)) {
return false;
}
$definition = $this->definition($key);
if (! $definition) {
return false;
}
return $this->roleListed($member, $definition['refer_roles'] ?? []);
}
/**
* Doctor with a specialty desk: non-GP specialty matching a catalog module, or
* assigned to a provisioned specialty department. GPs and non-doctors are false.
*/
public function isDeskSpecialist(Organization $organization, Member $member): bool
{
if ((string) $member->role !== 'doctor') {
return false;
}
foreach (array_keys($this->catalog()) as $key) {
if ($this->specialistBelongsToModule($organization, $member, $key)) {
return true;
}
}
return false;
}
/**
* Whether this doctor belongs to the module via department assignment or specialty keywords.
*/
public function specialistBelongsToModule(Organization $organization, Member $member, string $key): bool
{
return $this->doctorAssignedToModule($organization, $member, $key)
|| $this->doctorSpecialtyMatchesModule($organization, $member, $key);
}
/**
* @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
{
$definition = $this->definition($key);
if (! $definition) {
return false;
}
$departmentType = (string) ($definition['department_type'] ?? '');
$provisionedIds = data_get(
$organization->settings,
"specialty_module_provisioning.{$key}.department_ids",
[],
);
$provisionedIds = is_array($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);
return Practitioner::owned((string) $organization->owner_ref)
->where('organization_id', $organization->id)
->where('is_active', true)
->where(function ($query) use ($member) {
$query->where('member_id', $member->id)
->orWhere('user_ref', $member->user_ref);
})
->with(['department', 'branches'])
->get()
->filter(function (Practitioner $practitioner) use ($branchId) {
if (! $branchId) {
return true;
}
return in_array((int) $branchId, $practitioner->assignedBranchIds(), true);
});
}
public function canManage(Organization $organization): bool
{
return $this->plans->hasFeature($organization, 'specialty_modules');
}
/**
* @param array<string, bool> $desired module key => enabled
* @return array{activated: list<string>, deactivated: list<string>, errors: list<string>}
*/
public function sync(Organization $organization, string $ownerRef, array $desired): array
{
$activated = [];
$deactivated = [];
$errors = [];
foreach ($this->catalog() as $key => $definition) {
$want = (bool) ($desired[$key] ?? false);
if ($this->isDefaultOnPaidPlans($key) && $this->plans->hasPaidPlan($organization)) {
$want = true;
}
$have = $this->isEnabled($organization, $key);
if ($want === $have) {
// Still ensure defaults are provisioned even when already "enabled".
if ($want && $this->isDefaultOnPaidPlans($key)
&& ! data_get($organization->settings, "specialty_module_provisioning.{$key}.active")) {
try {
$this->activate($organization, $ownerRef, $key);
$organization->refresh();
} catch (\Throwable $e) {
$errors[] = ($definition['label'] ?? $key).': '.$e->getMessage();
}
}
continue;
}
try {
if ($want) {
$this->activate($organization, $ownerRef, $key);
$activated[] = $key;
} else {
$this->deactivate($organization, $ownerRef, $key);
$deactivated[] = $key;
}
$organization->refresh();
} catch (\Throwable $e) {
Log::warning('specialty_module.sync_failed', [
'key' => $key,
'want' => $want,
'message' => $e->getMessage(),
]);
$errors[] = ($definition['label'] ?? $key).': '.$e->getMessage();
}
}
return compact('activated', 'deactivated', 'errors');
}
public function activate(Organization $organization, string $ownerRef, string $key): void
{
$definition = $this->definition($key);
if (! $definition) {
throw new \InvalidArgumentException("Unknown specialty module [{$key}].");
}
if (! $this->canManage($organization)) {
throw new \RuntimeException('Specialty modules require Care Pro or Enterprise.');
}
$settings = $organization->settings ?? [];
$modules = is_array($settings['specialty_modules'] ?? null) ? $settings['specialty_modules'] : [];
$modules[$key] = true;
$settings['specialty_modules'] = $modules;
$departments = $this->provisionDepartments($organization, $ownerRef, $definition);
$queueStubs = $this->provisionQueueStubs($organization, $ownerRef, $key, $definition);
$provisioning = is_array($settings['specialty_module_provisioning'] ?? null)
? $settings['specialty_module_provisioning']
: [];
$provisioning[$key] = [
'active' => true,
'department_ids' => $departments,
'queues' => $queueStubs,
'activated_at' => now()->toIso8601String(),
];
$settings['specialty_module_provisioning'] = $provisioning;
$organization->update(['settings' => $settings]);
// Seed billable specialty services into provisioning (Billing Engine catalog).
try {
app(SpecialtyShellService::class)->seedServices($organization->fresh(), $key);
} catch (\Throwable $e) {
Log::warning('specialty_module.service_catalog_seed_failed', [
'key' => $key,
'message' => $e->getMessage(),
]);
}
$fresh = $organization->fresh();
if (data_get($fresh?->settings, 'queue_integration_enabled')) {
$provisioner = app(CareQueueProvisioner::class);
$branches = Branch::owned($ownerRef)
->where('organization_id', $organization->id)
->where('is_active', true)
->pluck('id');
foreach ($branches as $branchId) {
try {
$provisioner->ensure($fresh, $key, (int) $branchId);
} catch (\Throwable $e) {
Log::warning('specialty_module.native_queue_provision_failed', [
'key' => $key,
'branch_id' => $branchId,
'message' => $e->getMessage(),
]);
}
}
}
}
public function deactivate(Organization $organization, string $ownerRef, string $key): void
{
if (! $this->definition($key)) {
throw new \InvalidArgumentException("Unknown specialty module [{$key}].");
}
if ($this->isDefaultOnPaidPlans($key) && $this->plans->hasPaidPlan($organization)) {
throw new \RuntimeException(($this->definition($key)['label'] ?? $key).' stays enabled on Pro and Enterprise.');
}
$settings = $organization->settings ?? [];
$modules = is_array($settings['specialty_modules'] ?? null) ? $settings['specialty_modules'] : [];
$modules[$key] = false;
$settings['specialty_modules'] = $modules;
$provisioning = is_array($settings['specialty_module_provisioning'] ?? null)
? $settings['specialty_module_provisioning']
: [];
$record = is_array($provisioning[$key] ?? null) ? $provisioning[$key] : [];
$departmentIds = $record['department_ids'] ?? [];
if (is_array($departmentIds) && $departmentIds !== []) {
Department::owned($ownerRef)
->whereIn('id', $departmentIds)
->update(['is_active' => false]);
}
$queues = is_array($record['queues'] ?? null) ? $record['queues'] : [];
foreach ($queues as $i => $queue) {
$queues[$i]['active'] = false;
}
if (data_get($settings, 'queue_integration_enabled') && $queues !== []) {
try {
CareServiceQueue::query()
->where('organization_id', $organization->id)
->where('context', $key)
->update(['is_active' => false]);
} catch (\Throwable $e) {
Log::warning('specialty_module.queue_deactivate_failed', [
'key' => $key,
'message' => $e->getMessage(),
]);
}
}
$provisioning[$key] = array_merge($record, [
'active' => false,
'queues' => $queues,
'deactivated_at' => now()->toIso8601String(),
]);
$settings['specialty_module_provisioning'] = $provisioning;
$organization->update(['settings' => $settings]);
}
/**
* @param array<string, mixed> $definition
* @return list<int>
*/
protected function provisionDepartments(Organization $organization, string $ownerRef, array $definition): array
{
$type = (string) ($definition['department_type'] ?? 'general');
$name = (string) ($definition['department_name'] ?? $definition['label'] ?? 'Specialty');
$branches = Branch::owned($ownerRef)
->where('organization_id', $organization->id)
->where('is_active', true)
->get();
$ids = [];
foreach ($branches as $branch) {
$department = Department::withTrashed()
->owned($ownerRef)
->where('branch_id', $branch->id)
->where('type', $type)
->first();
if ($department) {
if ($department->trashed()) {
$department->restore();
}
$department->update([
'name' => $name,
'is_active' => true,
]);
} else {
$department = Department::create([
'owner_ref' => $ownerRef,
'branch_id' => $branch->id,
'name' => $name,
'type' => $type,
'is_active' => true,
]);
}
$ids[] = (int) $department->id;
}
return $ids;
}
/**
* Care-side queue stubs (branch-aware). When Queue integration is enabled,
* activate() creates/links real Queue queues + counters and stores UUIDs here.
*
* @param array<string, mixed> $definition
* @return list<array<string, mixed>>
*/
protected function provisionQueueStubs(
Organization $organization,
string $ownerRef,
string $key,
array $definition,
): array {
$branches = Branch::owned($ownerRef)
->where('organization_id', $organization->id)
->where('is_active', true)
->get();
$existingByBranch = collect(
data_get($organization->settings, "specialty_module_provisioning.{$key}.queues", [])
)->keyBy(fn ($q) => (string) ($q['branch_id'] ?? ''));
$stubs = [];
foreach ($branches as $branch) {
$prior = is_array($existingByBranch->get((string) $branch->id))
? $existingByBranch->get((string) $branch->id)
: [];
$stubs[] = [
'module' => $key,
'branch_id' => $branch->id,
'branch_name' => $branch->name,
'name' => (string) ($definition['queue_name'] ?? $definition['label']),
'prefix' => (string) ($definition['queue_prefix'] ?? strtoupper(substr($key, 0, 3))),
'active' => true,
'synced' => false,
'queue_uuid' => $prior['queue_uuid'] ?? null,
'counter_uuid' => $prior['counter_uuid'] ?? null,
'queue_external_key' => $prior['queue_external_key']
?? "care:specialty:{$key}:queue:{$branch->id}",
'counter_external_key' => $prior['counter_external_key']
?? "care:specialty:{$key}:counter:{$branch->id}",
];
}
return $stubs;
}
/**
* @return list<array<string, mixed>>
*/
public function queueStubsFor(Organization $organization, string $key): array
{
$queues = data_get($organization->settings, "specialty_module_provisioning.{$key}.queues", []);
return is_array($queues) ? array_values(array_filter($queues, fn ($q) => (bool) ($q['active'] ?? false))) : [];
}
}