Deploy Ladill Care / deploy (push) Successful in 57s
Replace broad doctor/nurse specialty access with granular roles and primary apps, permission inheritance for lab/BB managers, and cannot-rules for discharge, lab approve, and cashier vs billing officer. Co-authored-by: Cursor <cursoragent@cursor.com>
919 lines
32 KiB
PHP
919 lines
32 KiB
PHP
<?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
|
||
{
|
||
/** @var array<string, \Illuminate\Support\Collection<int, Practitioner>> */
|
||
protected array $practitionersCache = [];
|
||
|
||
/** @var array<string, bool> */
|
||
protected array $deskSpecialistCache = [];
|
||
|
||
/** @var array<string, list<array{key: string, definition: array<string, mixed>, access_level: string}>> */
|
||
protected array $enabledModulesForMemberCache = [];
|
||
|
||
/** @var array<string, string> */
|
||
protected array $memberAccessLevelCache = [];
|
||
|
||
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
|
||
{
|
||
$cacheKey = $organization->id.'|'.($member?->id ?? 'guest');
|
||
if (isset($this->enabledModulesForMemberCache[$cacheKey])) {
|
||
return $this->enabledModulesForMemberCache[$cacheKey];
|
||
}
|
||
|
||
$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 $this->enabledModulesForMemberCache[$cacheKey] = $out;
|
||
}
|
||
|
||
/**
|
||
* Whether the sidebar should show the Specialty nav group.
|
||
*
|
||
* Single-app specialists (dentist, radiologist, …) and legacy desk
|
||
* specialists only receive their own module(s), so a Specialty group is
|
||
* redundant — they use dashboard cards / direct routes instead.
|
||
* Multi-app clinical roles keep the group.
|
||
*/
|
||
public function shouldShowSpecialtyNav(Organization $organization, ?Member $member): bool
|
||
{
|
||
if (! $member) {
|
||
return false;
|
||
}
|
||
|
||
$modules = $this->enabledModulesForMember($organization, $member);
|
||
if ($modules === []) {
|
||
return false;
|
||
}
|
||
|
||
$permissions = app(CarePermissions::class);
|
||
if ($permissions->isSingleAppSpecialist($member)) {
|
||
return false;
|
||
}
|
||
|
||
if ($this->isDeskSpecialist($organization, $member)) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* @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
|
||
{
|
||
$cacheKey = $organization->id.'|'.($member?->id ?? 'guest').'|'.$key;
|
||
if (isset($this->memberAccessLevelCache[$cacheKey])) {
|
||
return $this->memberAccessLevelCache[$cacheKey];
|
||
}
|
||
|
||
if (! $this->isEnabled($organization, $key)) {
|
||
return $this->memberAccessLevelCache[$cacheKey] = 'none';
|
||
}
|
||
|
||
$definition = $this->definition($key);
|
||
if (! $definition || ! $member) {
|
||
return $this->memberAccessLevelCache[$cacheKey] = 'none';
|
||
}
|
||
|
||
if (app(CarePermissions::class)->isAdmin($member)) {
|
||
return $this->memberAccessLevelCache[$cacheKey] = 'manage';
|
||
}
|
||
|
||
if ($this->memberCanManage($organization, $member, $key)) {
|
||
return $this->memberAccessLevelCache[$cacheKey] = 'manage';
|
||
}
|
||
|
||
// Use gated helpers so desk specialists do not inherit GP view/refer on other modules.
|
||
if ($this->memberCanRefer($organization, $member, $key)) {
|
||
return $this->memberAccessLevelCache[$cacheKey] = 'refer';
|
||
}
|
||
if ($this->memberCanView($organization, $member, $key)) {
|
||
return $this->memberAccessLevelCache[$cacheKey] = 'view';
|
||
}
|
||
|
||
return $this->memberAccessLevelCache[$cacheKey] = '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.
|
||
*
|
||
* Role primary apps (CarePermissions) are the source of truth for the
|
||
* RBAC matrix. Legacy `doctor` desk specialists still use keyword matching.
|
||
*/
|
||
public function memberCanManage(Organization $organization, ?Member $member, string $key): bool
|
||
{
|
||
if (! $this->isEnabled($organization, $key) || ! $member) {
|
||
return false;
|
||
}
|
||
|
||
$permissions = app(CarePermissions::class);
|
||
if ($permissions->isAdmin($member)) {
|
||
return true;
|
||
}
|
||
|
||
$definition = $this->definition($key);
|
||
if (! $definition) {
|
||
return false;
|
||
}
|
||
|
||
// Department managers get analytics view, not clinical manage.
|
||
if ((string) $member->role === 'department_manager') {
|
||
return false;
|
||
}
|
||
|
||
$primaryApps = $permissions->primaryAppsFor($member);
|
||
|
||
// Matrix-driven roles (including GP / EP / specialists with app lists).
|
||
if (is_array($primaryApps)) {
|
||
// Legacy doctor + specialty desk: only matching modules.
|
||
if ((string) $member->role === 'doctor'
|
||
&& $this->isDeskSpecialist($organization, $member)) {
|
||
return $this->specialistBelongsToModule($organization, $member, $key);
|
||
}
|
||
|
||
return in_array($key, $primaryApps, true);
|
||
}
|
||
|
||
// 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 (in_array($role, ['doctor', 'general_physician'], true) && $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;
|
||
}
|
||
|
||
$permissions = app(CarePermissions::class);
|
||
if ((string) $member->role === 'department_manager') {
|
||
return true;
|
||
}
|
||
|
||
$primaryApps = $permissions->primaryAppsFor($member);
|
||
if (is_array($primaryApps)) {
|
||
// Matrix roles: view only what they can manage (no cross-module GP view
|
||
// unless listed — general_physician gets cardiology manage via apps).
|
||
if ((string) $member->role === 'doctor'
|
||
&& $this->isDeskSpecialist($organization, $member)) {
|
||
return $this->specialistBelongsToModule($organization, $member, $key);
|
||
}
|
||
|
||
// Legacy doctor (non-desk) / GP: keep refer/view on restricted modules
|
||
// outside primary manage via config view_roles when role is doctor/GP.
|
||
if (in_array((string) $member->role, ['doctor', 'general_physician'], true)
|
||
&& ! in_array($key, $primaryApps, true)) {
|
||
$definition = $this->definition($key);
|
||
if ($definition && $this->roleListed($member, $definition['view_roles'] ?? [])) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return in_array($key, $primaryApps, true);
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
$permissions = app(CarePermissions::class);
|
||
$primaryApps = $permissions->primaryAppsFor($member);
|
||
if (is_array($primaryApps)) {
|
||
if ((string) $member->role === 'doctor'
|
||
&& $this->isDeskSpecialist($organization, $member)) {
|
||
return $this->specialistBelongsToModule($organization, $member, $key);
|
||
}
|
||
|
||
if (in_array((string) $member->role, ['doctor', 'general_physician'], true)
|
||
&& ! in_array($key, $primaryApps, true)) {
|
||
$definition = $this->definition($key);
|
||
if ($definition && $this->roleListed($member, $definition['refer_roles'] ?? [])) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return in_array($key, $primaryApps, true);
|
||
}
|
||
|
||
// 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'] ?? []);
|
||
}
|
||
|
||
/**
|
||
* Legacy doctor with a specialty desk: non-GP specialty matching a catalog
|
||
* module, or assigned to a provisioned specialty department.
|
||
* Dedicated specialist roles use primary apps instead.
|
||
*/
|
||
public function isDeskSpecialist(Organization $organization, Member $member): bool
|
||
{
|
||
$cacheKey = $organization->id.'|'.$member->id;
|
||
if (isset($this->deskSpecialistCache[$cacheKey])) {
|
||
return $this->deskSpecialistCache[$cacheKey];
|
||
}
|
||
|
||
if (! in_array((string) $member->role, ['doctor'], true)) {
|
||
return $this->deskSpecialistCache[$cacheKey] = false;
|
||
}
|
||
|
||
foreach (array_keys($this->catalog()) as $key) {
|
||
if ($this->specialistBelongsToModule($organization, $member, $key)) {
|
||
return $this->deskSpecialistCache[$cacheKey] = true;
|
||
}
|
||
}
|
||
|
||
return $this->deskSpecialistCache[$cacheKey] = 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) {
|
||
if ($this->specialtyKeywordMatches($hay, strtolower((string) $keyword))) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Match specialty text to a keyword without short-token false positives
|
||
* (e.g. "ent" must not match "dentistry"; "ear" must not match "year").
|
||
*/
|
||
protected function specialtyKeywordMatches(string $haystackLower, string $needleLower): bool
|
||
{
|
||
$needleLower = trim($needleLower);
|
||
if ($needleLower === '' || $haystackLower === '') {
|
||
return false;
|
||
}
|
||
|
||
if ($haystackLower === $needleLower) {
|
||
return true;
|
||
}
|
||
|
||
$quoted = preg_quote($needleLower, '/');
|
||
|
||
// Whole-word match covers short tokens (ent, ear, eye) and multi-word labels.
|
||
if (preg_match('/\b'.$quoted.'\b/u', $haystackLower) === 1) {
|
||
return true;
|
||
}
|
||
|
||
// Longer stems may prefix a token (dent→dentistry, cardio→cardiology).
|
||
if (strlen($needleLower) >= 4 && preg_match('/\b'.$quoted.'/u', $haystackLower) === 1) {
|
||
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)
|
||
{
|
||
$cacheKey = $organization->id.'|'.$member->id;
|
||
if (isset($this->practitionersCache[$cacheKey])) {
|
||
return $this->practitionersCache[$cacheKey];
|
||
}
|
||
|
||
$branchId = app(OrganizationResolver::class)->branchScope($member);
|
||
|
||
return $this->practitionersCache[$cacheKey] = 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))) : [];
|
||
}
|
||
}
|