From b44ed50fe723d106cf0ce6e5a0cd5ae1bc6f2e89 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sun, 19 Jul 2026 16:02:16 +0000 Subject: [PATCH] Memoize specialty nav and branch lookups to stop per-request N+1. Sidebar was re-querying practitioners for every specialty module and also calling ensureDefaultModulesProvisioned on each page render, which showed up as multi-second PHP-FPM slow logs on Care. Co-authored-by: Cursor --- app/Providers/AppServiceProvider.php | 5 +- app/Services/Care/OrganizationResolver.php | 26 +++++++-- app/Services/Care/SpecialtyModuleService.php | 56 +++++++++++++++----- resources/views/partials/sidebar.blade.php | 9 ++-- 4 files changed, 72 insertions(+), 24 deletions(-) diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 23583b4..f427ba7 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -7,6 +7,7 @@ use App\Listeners\PlatformServiceEventListener; use App\Models\User; use App\Services\Care\OrganizationResolver; use App\Services\Care\PlanService; +use App\Services\Care\SpecialtyModuleService; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; use Illuminate\Support\Facades\Event; @@ -18,7 +19,9 @@ class AppServiceProvider extends ServiceProvider { public function register(): void { - // + // Request-scoped memoization for specialty nav / branch scope (avoids N+1 per module). + $this->app->singleton(OrganizationResolver::class); + $this->app->singleton(SpecialtyModuleService::class); } public function boot(): void diff --git a/app/Services/Care/OrganizationResolver.php b/app/Services/Care/OrganizationResolver.php index 13a47ce..59a749f 100644 --- a/app/Services/Care/OrganizationResolver.php +++ b/app/Services/Care/OrganizationResolver.php @@ -15,6 +15,12 @@ use Illuminate\Support\Str; class OrganizationResolver { + /** @var array> */ + protected array $doctorAssignedBranchIdsCache = []; + + /** @var array */ + protected array $membershipCache = []; + public function resolveForUser(User $user): ?Organization { $member = $this->membershipFor($user); @@ -48,19 +54,24 @@ class OrganizationResolver */ public function membershipFor(User $user): ?Member { + $cacheKey = $user->ownerRef(); + if (array_key_exists($cacheKey, $this->membershipCache)) { + return $this->membershipCache[$cacheKey]; + } + $member = Member::query()->where('user_ref', $user->ownerRef())->first(); if ($member) { - return $member; + return $this->membershipCache[$cacheKey] = $member; } $email = strtolower(trim((string) $user->email)); if ($email === '' || $email === $user->ownerRef()) { - return null; + return $this->membershipCache[$cacheKey] = null; } $member = Member::query()->where('user_ref', $email)->first(); - return $member ? $this->remapMemberUserRef($member, $user) : null; + return $this->membershipCache[$cacheKey] = ($member ? $this->remapMemberUserRef($member, $user) : null); } /** @@ -291,6 +302,11 @@ class OrganizationResolver */ public function doctorAssignedBranchIds(Member $member): array { + $cacheKey = (string) $member->id; + if (isset($this->doctorAssignedBranchIdsCache[$cacheKey])) { + return $this->doctorAssignedBranchIdsCache[$cacheKey]; + } + $practitioners = Practitioner::query() ->where('is_active', true) ->where(function ($query) use ($member) { @@ -309,10 +325,10 @@ class OrganizationResolver $ids = array_values(array_unique(array_map('intval', $ids))); if ($ids === [] && $member->branch_id) { - return [(int) $member->branch_id]; + $ids = [(int) $member->branch_id]; } - return $ids; + return $this->doctorAssignedBranchIdsCache[$cacheKey] = $ids; } public function mayAccessBranch(?Member $member, ?int $branchId): bool diff --git a/app/Services/Care/SpecialtyModuleService.php b/app/Services/Care/SpecialtyModuleService.php index 38ef9d0..ced5f63 100644 --- a/app/Services/Care/SpecialtyModuleService.php +++ b/app/Services/Care/SpecialtyModuleService.php @@ -21,6 +21,18 @@ use Illuminate\Support\Facades\Log; */ class SpecialtyModuleService { + /** @var array> */ + protected array $practitionersCache = []; + + /** @var array */ + protected array $deskSpecialistCache = []; + + /** @var array, access_level: string}>> */ + protected array $enabledModulesForMemberCache = []; + + /** @var array */ + protected array $memberAccessLevelCache = []; + public function __construct( protected PlanService $plans, ) {} @@ -168,6 +180,11 @@ class SpecialtyModuleService */ 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']); @@ -177,7 +194,7 @@ class SpecialtyModuleService $out[] = array_merge($item, ['access_level' => $level]); } - return $out; + return $this->enabledModulesForMemberCache[$cacheKey] = $out; } /** @@ -223,32 +240,37 @@ class SpecialtyModuleService */ 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 'none'; + return $this->memberAccessLevelCache[$cacheKey] = 'none'; } $definition = $this->definition($key); if (! $definition || ! $member) { - return 'none'; + return $this->memberAccessLevelCache[$cacheKey] = 'none'; } if (app(CarePermissions::class)->isAdmin($member)) { - return 'manage'; + return $this->memberAccessLevelCache[$cacheKey] = 'manage'; } if ($this->memberCanManage($organization, $member, $key)) { - return 'manage'; + 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 'refer'; + return $this->memberAccessLevelCache[$cacheKey] = 'refer'; } if ($this->memberCanView($organization, $member, $key)) { - return 'view'; + return $this->memberAccessLevelCache[$cacheKey] = 'view'; } - return 'none'; + return $this->memberAccessLevelCache[$cacheKey] = 'none'; } /** @@ -364,17 +386,22 @@ class SpecialtyModuleService */ public function isDeskSpecialist(Organization $organization, Member $member): bool { + $cacheKey = $organization->id.'|'.$member->id; + if (isset($this->deskSpecialistCache[$cacheKey])) { + return $this->deskSpecialistCache[$cacheKey]; + } + if ((string) $member->role !== 'doctor') { - return false; + return $this->deskSpecialistCache[$cacheKey] = false; } foreach (array_keys($this->catalog()) as $key) { if ($this->specialistBelongsToModule($organization, $member, $key)) { - return true; + return $this->deskSpecialistCache[$cacheKey] = true; } } - return false; + return $this->deskSpecialistCache[$cacheKey] = false; } /** @@ -508,9 +535,14 @@ class SpecialtyModuleService */ 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 Practitioner::owned((string) $organization->owner_ref) + return $this->practitionersCache[$cacheKey] = Practitioner::owned((string) $organization->owner_ref) ->where('organization_id', $organization->id) ->where('is_active', true) ->where(function ($query) use ($member) { diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 4010cef..fa92e33 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -81,12 +81,9 @@ // Desk specialists skip this group (redundant single-desk list); dashboard cards remain. if ($permissions->handlesFloorCare($member) && $organization) { $specialtyService = app(\App\Services\Care\SpecialtyModuleService::class); - $specialtyService->ensureDefaultModulesProvisioned( - $organization, - (string) $organization->owner_ref, - ); - if ($specialtyService->shouldShowSpecialtyNav($organization, $member)) { - foreach ($specialtyService->enabledModulesForMember($organization, $member) as $item) { + $specialtyModulesForNav = $specialtyService->enabledModulesForMember($organization, $member); + if ($specialtyModulesForNav !== [] && $specialtyService->shouldShowSpecialtyNav($organization, $member)) { + foreach ($specialtyModulesForNav as $item) { $key = $item['key']; $label = $item['definition']['nav_label'] ?? $item['definition']['label'] ?? $key; $nav[] = [