Files
ladill-care/app/Services/Care/SpecialtyModuleService.php
T
isaaccladandCursor 1e00c31b8b
Deploy Ladill Care / deploy (push) Successful in 1m2s
Give each specialty module a distinct Heroicon-style icon.
Icon names live on the specialty catalog; SVG paths resolve through a shared map and render in sidebar, dashboard, settings, and the specialty shell.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 18:30:14 +00:00

717 lines
24 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';
}
$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
{
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;
}
$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;
}
$definition = $this->definition($key);
if (! $definition) {
return false;
}
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
{
$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))) : [];
}
}