Link demo specialty desks after SSO remap and tighten keyword matching.
Deploy Ladill Care / deploy (push) Successful in 58s

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 <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-18 22:18:49 +00:00
co-authored by Cursor
parent d02760c527
commit c8a7115e49
4 changed files with 133 additions and 11 deletions
+32 -9
View File
@@ -249,10 +249,7 @@ class DemoTenantSeeder
return; return;
} }
$member = Member::query() $member = $this->findStaffMemberByEmail($organization, $doctorEmail);
->where('organization_id', $organization->id)
->where('user_ref', $doctorEmail)
->first();
// Link the demo doctor to a single desk on their home branch (not every site). // Link the demo doctor to a single desk on their home branch (not every site).
$homeBranchId = (int) ($member?->branch_id ?: ($branches[0]->id ?? 0)); $homeBranchId = (int) ($member?->branch_id ?: ($branches[0]->id ?? 0));
@@ -265,7 +262,7 @@ class DemoTenantSeeder
} }
$practitioner->forceFill([ $practitioner->forceFill([
'user_ref' => $doctorEmail, 'user_ref' => $member?->user_ref ?: $doctorEmail,
'member_id' => $member?->id, 'member_id' => $member?->id,
'name' => $doctorName !== '' ? $doctorName : $practitioner->name, 'name' => $doctorName !== '' ? $doctorName : $practitioner->name,
])->save(); ])->save();
@@ -649,6 +646,34 @@ class DemoTenantSeeder
return $branches; 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). * 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. * 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']; $moduleReasons = $reasons[$key] ?? ['Specialty visit'];
$staffEmail = sprintf('demo-%s-%s@ladill.com', $plan, $key); $staffEmail = sprintf('demo-%s-%s@ladill.com', $plan, $key);
$moduleMembers[$key] = Member::query() // Resolve by email or SSO-remapped public_id (seedStaffMembers prefers public_id).
->where('organization_id', $organization->id) $moduleMembers[$key] = $this->findStaffMemberByEmail($organization, $staffEmail);
->where('user_ref', $staffEmail)
->first();
foreach ($branches as $branchIndex => $branch) { foreach ($branches as $branchIndex => $branch) {
$department = Department::owned($ownerRef) $department = Department::owned($ownerRef)
+31 -2
View File
@@ -421,8 +421,7 @@ class SpecialtyModuleService
continue; continue;
} }
foreach ($keywords as $keyword) { foreach ($keywords as $keyword) {
$needle = strtolower((string) $keyword); if ($this->specialtyKeywordMatches($hay, strtolower((string) $keyword))) {
if ($needle !== '' && str_contains($hay, $needle)) {
return true; return true;
} }
} }
@@ -431,6 +430,36 @@ class SpecialtyModuleService
return false; 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 protected function isGeneralPracticeSpecialty(string $specialtyLower): bool
{ {
if (in_array($specialtyLower, [ if (in_array($specialtyLower, [
+19
View File
@@ -198,6 +198,25 @@ class CareSpecialtyAccessTest extends TestCase
->assertDontSee('Blood Bank'); ->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 public function test_gp_can_refer_into_restricted_specialty_queue(): void
{ {
[$doctor, $member] = $this->makeStaff('doctor', 'gp2'); [$doctor, $member] = $this->makeStaff('doctor', 'gp2');
+51
View File
@@ -378,4 +378,55 @@ class DemoSeedCommandTest extends TestCase
$this->assertNotSame('unresolved', $waiter->queue_routing_status); $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);
}
} }