From c8a7115e4926ee6ae455397819a182a119f859e1 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sat, 18 Jul 2026 22:18:49 +0000 Subject: [PATCH] Link demo specialty desks after SSO remap and tighten keyword matching. Specialty seeder looked up members by email user_ref only, so remapped demo logins never got practitioner.member_id and saw every module. Also stop short keywords like "ent" matching "dentistry". Co-authored-by: Cursor --- app/Services/Care/DemoTenantSeeder.php | 41 ++++++++++++---- app/Services/Care/SpecialtyModuleService.php | 33 ++++++++++++- tests/Feature/CareSpecialtyAccessTest.php | 19 ++++++++ tests/Feature/DemoSeedCommandTest.php | 51 ++++++++++++++++++++ 4 files changed, 133 insertions(+), 11 deletions(-) diff --git a/app/Services/Care/DemoTenantSeeder.php b/app/Services/Care/DemoTenantSeeder.php index acb2bc8..d0b7495 100644 --- a/app/Services/Care/DemoTenantSeeder.php +++ b/app/Services/Care/DemoTenantSeeder.php @@ -249,10 +249,7 @@ class DemoTenantSeeder return; } - $member = Member::query() - ->where('organization_id', $organization->id) - ->where('user_ref', $doctorEmail) - ->first(); + $member = $this->findStaffMemberByEmail($organization, $doctorEmail); // Link the demo doctor to a single desk on their home branch (not every site). $homeBranchId = (int) ($member?->branch_id ?: ($branches[0]->id ?? 0)); @@ -265,7 +262,7 @@ class DemoTenantSeeder } $practitioner->forceFill([ - 'user_ref' => $doctorEmail, + 'user_ref' => $member?->user_ref ?: $doctorEmail, 'member_id' => $member?->id, 'name' => $doctorName !== '' ? $doctorName : $practitioner->name, ])->save(); @@ -649,6 +646,34 @@ class DemoTenantSeeder return $branches; } + /** + * Find a demo staff Member by login email, including SSO-remapped public_id user_ref. + */ + private function findStaffMemberByEmail(Organization $organization, string $email): ?Member + { + $email = strtolower(trim($email)); + if ($email === '') { + return null; + } + + $publicId = User::query() + ->whereRaw('LOWER(email) = ?', [$email]) + ->value('public_id'); + + return Member::query() + ->where('organization_id', $organization->id) + ->where(function ($query) use ($email, $publicId) { + $query->where('user_ref', $email); + if (is_string($publicId) && $publicId !== '') { + $query->orWhere('user_ref', $publicId); + } + }) + ->orderByRaw('CASE WHEN user_ref = ? THEN 0 ELSE 1 END', [ + is_string($publicId) && $publicId !== '' ? $publicId : $email, + ]) + ->first(); + } + /** * Local Member rows for DemoWorld staff (user_ref = email until SSO remaps to public_id). * Multi-branch demos spread staff across branches so each site has a role roster. @@ -872,10 +897,8 @@ class DemoTenantSeeder $moduleReasons = $reasons[$key] ?? ['Specialty visit']; $staffEmail = sprintf('demo-%s-%s@ladill.com', $plan, $key); - $moduleMembers[$key] = Member::query() - ->where('organization_id', $organization->id) - ->where('user_ref', $staffEmail) - ->first(); + // Resolve by email or SSO-remapped public_id (seedStaffMembers prefers public_id). + $moduleMembers[$key] = $this->findStaffMemberByEmail($organization, $staffEmail); foreach ($branches as $branchIndex => $branch) { $department = Department::owned($ownerRef) diff --git a/app/Services/Care/SpecialtyModuleService.php b/app/Services/Care/SpecialtyModuleService.php index 1fadbc9..8d0d07b 100644 --- a/app/Services/Care/SpecialtyModuleService.php +++ b/app/Services/Care/SpecialtyModuleService.php @@ -421,8 +421,7 @@ class SpecialtyModuleService continue; } foreach ($keywords as $keyword) { - $needle = strtolower((string) $keyword); - if ($needle !== '' && str_contains($hay, $needle)) { + if ($this->specialtyKeywordMatches($hay, strtolower((string) $keyword))) { return true; } } @@ -431,6 +430,36 @@ class SpecialtyModuleService 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, [ diff --git a/tests/Feature/CareSpecialtyAccessTest.php b/tests/Feature/CareSpecialtyAccessTest.php index 579edf6..90c5f6a 100644 --- a/tests/Feature/CareSpecialtyAccessTest.php +++ b/tests/Feature/CareSpecialtyAccessTest.php @@ -198,6 +198,25 @@ class CareSpecialtyAccessTest extends TestCase ->assertDontSee('Blood Bank'); } + public function test_dentistry_specialty_does_not_match_ent_keyword(): void + { + [$doctor, $member] = $this->makeStaff('doctor', 'dentist-ent-trap'); + Practitioner::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'member_id' => $member->id, + 'user_ref' => $doctor->public_id, + 'name' => 'Dr Dentist', + 'specialty' => 'Dentistry', + 'is_active' => true, + ]); + + $this->assertTrue($this->modules->specialistBelongsToModule($this->organization, $member, 'dentistry')); + $this->assertFalse($this->modules->specialistBelongsToModule($this->organization, $member, 'ent')); + $this->assertSame('none', $this->modules->memberAccessLevel($this->organization, $member, 'ent')); + } + public function test_gp_can_refer_into_restricted_specialty_queue(): void { [$doctor, $member] = $this->makeStaff('doctor', 'gp2'); diff --git a/tests/Feature/DemoSeedCommandTest.php b/tests/Feature/DemoSeedCommandTest.php index 68c2143..2580f09 100644 --- a/tests/Feature/DemoSeedCommandTest.php +++ b/tests/Feature/DemoSeedCommandTest.php @@ -378,4 +378,55 @@ class DemoSeedCommandTest extends TestCase $this->assertNotSame('unresolved', $waiter->queue_routing_status); } } + + public function test_specialty_doctor_links_after_sso_public_id_remap(): void + { + $owner = User::create([ + 'public_id' => 'demo-pro-specialty-link-owner', + 'name' => 'Ladill Demo (Pro)', + 'email' => 'demo-pro@ladill.com', + ]); + + $this->artisan('demo:seed', [ + 'identity' => 'demo-pro@ladill.com', + '--plan' => 'pro', + ])->assertSuccessful(); + + $org = Organization::query()->where('owner_ref', $owner->public_id)->firstOrFail(); + + $dentist = User::create([ + 'public_id' => 'demo-pro-dentistry-public-id', + 'name' => 'Dr. Ama Dental (Pro)', + 'email' => 'demo-pro-dentistry@ladill.com', + ]); + + $member = \App\Models\Member::query() + ->where('organization_id', $org->id) + ->where('user_ref', 'demo-pro-dentistry@ladill.com') + ->firstOrFail(); + + // Simulate Identity SSO remapping member.user_ref from email → public_id. + $member->forceFill(['user_ref' => $dentist->public_id])->save(); + + $this->artisan('demo:seed', [ + 'identity' => 'demo-pro@ladill.com', + '--plan' => 'pro', + ])->assertSuccessful(); + + $member->refresh(); + $this->assertSame($dentist->public_id, $member->user_ref); + + $linked = \App\Models\Practitioner::query() + ->where('organization_id', $org->id) + ->where('member_id', $member->id) + ->where('specialty', 'Dentistry') + ->get(); + $this->assertCount(1, $linked, 'Home-branch dentistry desk must link to the remapped member'); + $this->assertSame((int) $member->branch_id, (int) $linked->first()->branch_id); + + $modules = app(\App\Services\Care\SpecialtyModuleService::class); + $this->assertTrue($modules->isDeskSpecialist($org, $member)); + $keys = collect($modules->enabledModulesForMember($org, $member))->pluck('key')->all(); + $this->assertSame(['dentistry'], $keys); + } }