Link demo specialty desks after SSO remap and tighten keyword matching.
Deploy Ladill Care / deploy (push) Successful in 58s
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:
@@ -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)
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user