From c3219a1bf19c85c4aa37b1bf33511007bbae01bd Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sat, 18 Jul 2026 19:09:06 +0000 Subject: [PATCH] Fix nurse onboarding redirects and specialty Documents 404s. Resolve staff memberships by invite/demo email as well as public_id so nurses stay on their employer org; block staff from owner onboarding; keep Documents viewable with module access while upload stays manage-gated. Co-authored-by: Cursor --- .../Controllers/Care/OnboardingController.php | 8 + .../Care/SpecialtyModuleController.php | 1 + .../Middleware/EnsureOrganizationSetup.php | 5 + app/Services/Care/DemoTenantSeeder.php | 53 ++- app/Services/Care/OrganizationResolver.php | 85 +++- .../specialty/sections/workspace.blade.php | 2 +- tests/Feature/CareNurseOrgAccessTest.php | 384 ++++++++++++++++++ 7 files changed, 524 insertions(+), 14 deletions(-) create mode 100644 tests/Feature/CareNurseOrgAccessTest.php diff --git a/app/Http/Controllers/Care/OnboardingController.php b/app/Http/Controllers/Care/OnboardingController.php index 146190b..28f962a 100644 --- a/app/Http/Controllers/Care/OnboardingController.php +++ b/app/Http/Controllers/Care/OnboardingController.php @@ -26,6 +26,10 @@ class OnboardingController extends Controller return redirect()->route('care.dashboard'); } + if (! $this->organizations->canCompleteOnboarding($request->user())) { + abort(403, 'Your facility setup is incomplete. Ask an administrator to finish onboarding.'); + } + return view('care.onboarding.show', [ 'user' => $request->user(), 'timezones' => timezone_identifiers_list(), @@ -41,6 +45,10 @@ class OnboardingController extends Controller return redirect()->route('care.dashboard'); } + if (! $this->organizations->canCompleteOnboarding($request->user())) { + abort(403, 'Your facility setup is incomplete. Ask an administrator to finish onboarding.'); + } + $categoryKeys = array_keys($this->workflows->categories()); $templateKeys = array_keys($this->workflows->templates()); diff --git a/app/Http/Controllers/Care/SpecialtyModuleController.php b/app/Http/Controllers/Care/SpecialtyModuleController.php index 092ddba..81fee9d 100644 --- a/app/Http/Controllers/Care/SpecialtyModuleController.php +++ b/app/Http/Controllers/Care/SpecialtyModuleController.php @@ -347,6 +347,7 @@ class SpecialtyModuleController extends Controller $organization = $this->organization($request); $member = $this->member($request); + // Viewing docs is workspace GET (memberCanAccess / memberCanView). Upload needs manage. abort_unless($modules->memberCanAccess($organization, $member, $module), 403); abort_unless($modules->memberCanManage($organization, $member, $module), 403); abort_unless($visit->organization_id === $organization->id, 404); diff --git a/app/Http/Middleware/EnsureOrganizationSetup.php b/app/Http/Middleware/EnsureOrganizationSetup.php index 7ede739..c53dac8 100644 --- a/app/Http/Middleware/EnsureOrganizationSetup.php +++ b/app/Http/Middleware/EnsureOrganizationSetup.php @@ -25,6 +25,11 @@ class EnsureOrganizationSetup } if (! $this->organizations->isOnboarded($user)) { + // Staff already attached to a tenant must not see owner onboarding. + if ($this->organizations->membershipFor($user)) { + abort(403, 'Your facility setup is incomplete. Ask an administrator to finish onboarding.'); + } + return redirect()->route('care.onboarding.show'); } diff --git a/app/Services/Care/DemoTenantSeeder.php b/app/Services/Care/DemoTenantSeeder.php index 6c6208a..2e752e1 100644 --- a/app/Services/Care/DemoTenantSeeder.php +++ b/app/Services/Care/DemoTenantSeeder.php @@ -670,17 +670,54 @@ class DemoTenantSeeder $branch = $branches[$index % count($branches)]; } - Member::query()->updateOrCreate( - [ - 'organization_id' => $organization->id, - 'user_ref' => strtolower($staff['email']), - ], - [ + $email = strtolower((string) $staff['email']); + // Prefer remapped public_id when the staff user already exists locally, + // so reseeds do not orphan SSO sessions keyed by public_id. + $publicId = User::query() + ->whereRaw('LOWER(email) = ?', [$email]) + ->value('public_id'); + $userRef = is_string($publicId) && $publicId !== '' ? $publicId : $email; + + $existing = 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', [$userRef]) + ->first(); + + if ($existing) { + $existing->forceFill([ + 'user_ref' => $userRef, 'owner_ref' => $ownerRef, 'role' => $role, 'branch_id' => $role === 'hospital_admin' ? null : $branch?->id, - ], - ); + ])->save(); + + Member::query() + ->where('organization_id', $organization->id) + ->where('id', '!=', $existing->id) + ->where(function ($query) use ($email, $publicId) { + $query->where('user_ref', $email); + if (is_string($publicId) && $publicId !== '') { + $query->orWhere('user_ref', $publicId); + } + }) + ->delete(); + + continue; + } + + Member::query()->create([ + 'organization_id' => $organization->id, + 'user_ref' => $userRef, + 'owner_ref' => $ownerRef, + 'role' => $role, + 'branch_id' => $role === 'hospital_admin' ? null : $branch?->id, + ]); } } diff --git a/app/Services/Care/OrganizationResolver.php b/app/Services/Care/OrganizationResolver.php index 4cf8bc3..13ac5b6 100644 --- a/app/Services/Care/OrganizationResolver.php +++ b/app/Services/Care/OrganizationResolver.php @@ -17,9 +17,7 @@ class OrganizationResolver { public function resolveForUser(User $user): ?Organization { - $ref = $user->ownerRef(); - - $member = Member::where('user_ref', $ref)->first(); + $member = $this->membershipFor($user); if ($member) { $organization = Organization::query()->find($member->organization_id); if ($organization) { @@ -27,7 +25,7 @@ class OrganizationResolver } } - return Organization::owned($ref)->first(); + return Organization::owned($user->ownerRef())->first(); } public function forUser(User $user): Organization @@ -44,6 +42,40 @@ class OrganizationResolver && (bool) data_get($organization->settings, 'onboarded', false); } + /** + * Any Care membership for this user (public_id or invite/demo email key). + * Remaps email-keyed rows to public_id when found so staff stay linked after SSO. + */ + public function membershipFor(User $user): ?Member + { + $member = Member::query()->where('user_ref', $user->ownerRef())->first(); + if ($member) { + return $member; + } + + $email = strtolower(trim((string) $user->email)); + if ($email === '' || $email === $user->ownerRef()) { + return null; + } + + $member = Member::query()->where('user_ref', $email)->first(); + + return $member ? $this->remapMemberUserRef($member, $user) : null; + } + + /** + * True when the user may run owner onboarding (create a facility). + * Staff already attached to a tenant must not create a second org. + */ + public function canCompleteOnboarding(User $user): bool + { + if ($this->membershipFor($user)) { + return false; + } + + return ! $this->isOnboarded($user); + } + public function memberFor(User $user, ?Organization $organization = null): ?Member { $organization ??= $this->resolveForUser($user); @@ -51,9 +83,52 @@ class OrganizationResolver return null; } - return Member::where('organization_id', $organization->id) + $member = Member::query() + ->where('organization_id', $organization->id) ->where('user_ref', $user->ownerRef()) ->first(); + if ($member) { + return $member; + } + + $email = strtolower(trim((string) $user->email)); + if ($email === '') { + return null; + } + + $member = Member::query() + ->where('organization_id', $organization->id) + ->where('user_ref', $email) + ->first(); + + return $member ? $this->remapMemberUserRef($member, $user) : null; + } + + /** + * Promote invite/demo email user_ref keys to the stable OIDC public_id. + */ + protected function remapMemberUserRef(Member $member, User $user): Member + { + $ref = $user->ownerRef(); + if ($member->user_ref === $ref) { + return $member; + } + + $conflict = Member::query() + ->where('organization_id', $member->organization_id) + ->where('user_ref', $ref) + ->where('id', '!=', $member->id) + ->first(); + + if ($conflict) { + $member->delete(); + + return $conflict; + } + + $member->forceFill(['user_ref' => $ref])->save(); + + return $member->fresh() ?? $member; } public function ensureOwnerMember(User $user, Organization $organization): Member diff --git a/resources/views/care/specialty/sections/workspace.blade.php b/resources/views/care/specialty/sections/workspace.blade.php index 09ba0ae..376ba97 100644 --- a/resources/views/care/specialty/sections/workspace.blade.php +++ b/resources/views/care/specialty/sections/workspace.blade.php @@ -88,7 +88,7 @@

Documents

Specialty documents attach to the patient chart for this episode.

- @if ($canManageClinical ?? $canManageSpecialty ?? true) + @if ($canManageClinical ?? $canManageSpecialty ?? false)
@csrf diff --git a/tests/Feature/CareNurseOrgAccessTest.php b/tests/Feature/CareNurseOrgAccessTest.php new file mode 100644 index 0000000..b395014 --- /dev/null +++ b/tests/Feature/CareNurseOrgAccessTest.php @@ -0,0 +1,384 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $owner = User::create([ + 'public_id' => 'owner-pid', + 'name' => 'Owner', + 'email' => 'owner@example.com', + ]); + + $nurse = User::create([ + 'public_id' => 'nurse-pid', + 'name' => 'Nurse', + 'email' => 'demo-pro-nurse@ladill.com', + ]); + + $organization = Organization::create([ + 'owner_ref' => $owner->public_id, + 'name' => 'Clinic', + 'slug' => 'clinic', + 'settings' => [ + 'onboarded' => true, + 'plan' => 'pro', + 'plan_expires_at' => now()->addMonth()->toIso8601String(), + ], + ]); + + Member::create([ + 'owner_ref' => $owner->public_id, + 'organization_id' => $organization->id, + 'user_ref' => $owner->public_id, + 'role' => 'hospital_admin', + ]); + + // Demo seeder style: staff member keyed by email until SSO remaps. + Member::create([ + 'owner_ref' => $owner->public_id, + 'organization_id' => $organization->id, + 'user_ref' => strtolower($nurse->email), + 'role' => 'nurse', + ]); + + $resolver = app(OrganizationResolver::class); + $this->assertTrue($resolver->isOnboarded($nurse)); + $this->assertFalse($resolver->canCompleteOnboarding($nurse)); + $this->assertSame($organization->id, $resolver->resolveForUser($nurse)?->id); + + // Remap sticks so later requests resolve by public_id. + $this->assertDatabaseHas('care_members', [ + 'organization_id' => $organization->id, + 'user_ref' => $nurse->public_id, + 'role' => 'nurse', + ]); + + $this->actingAs($nurse) + ->get(route('care.dashboard')) + ->assertOk(); + + $this->actingAs($nurse) + ->get(route('care.onboarding.show')) + ->assertRedirect(route('care.dashboard')); + } + + public function test_nurse_who_wrongly_owns_empty_org_still_uses_employer_membership(): void + { + $this->withoutMiddleware(EnsurePlatformSession::class); + + $owner = User::create([ + 'public_id' => 'employer-owner', + 'name' => 'Owner', + 'email' => 'employer@example.com', + ]); + + $nurse = User::create([ + 'public_id' => 'orphan-nurse', + 'name' => 'Nurse', + 'email' => 'orphan-nurse@example.com', + ]); + + $employer = Organization::create([ + 'owner_ref' => $owner->public_id, + 'name' => 'Employer Clinic', + 'slug' => 'employer-clinic', + 'settings' => [ + 'onboarded' => true, + 'plan' => 'pro', + 'plan_expires_at' => now()->addMonth()->toIso8601String(), + 'specialty_modules' => ['emergency' => true], + ], + ]); + + // Orphan org from completing owner onboarding while membership was unresolved. + Organization::create([ + 'owner_ref' => $nurse->public_id, + 'name' => 'Nurse Own Clinic', + 'slug' => 'nurse-own-clinic', + 'settings' => ['onboarded' => true], + ]); + + Member::create([ + 'owner_ref' => $owner->public_id, + 'organization_id' => $employer->id, + 'user_ref' => strtolower($nurse->email), + 'role' => 'nurse', + ]); + + $branch = Branch::create([ + 'owner_ref' => $owner->public_id, + 'organization_id' => $employer->id, + 'name' => 'Main', + 'is_active' => true, + ]); + + app(SpecialtyModuleService::class)->activate($employer, $owner->public_id, 'emergency'); + $dept = Department::query()->where('type', 'emergency')->where('branch_id', $branch->id)->firstOrFail(); + + $patient = Patient::create([ + 'owner_ref' => $owner->public_id, + 'organization_id' => $employer->id, + 'branch_id' => $branch->id, + 'patient_number' => 'P-ORPHAN', + 'first_name' => 'Ama', + 'last_name' => 'Patient', + 'gender' => 'female', + 'date_of_birth' => '1990-01-01', + ]); + + $visit = Visit::create([ + 'owner_ref' => $owner->public_id, + 'organization_id' => $employer->id, + 'branch_id' => $branch->id, + 'patient_id' => $patient->id, + 'status' => Visit::STATUS_IN_PROGRESS, + 'checked_in_at' => now(), + ]); + + Appointment::create([ + 'owner_ref' => $owner->public_id, + 'organization_id' => $employer->id, + 'branch_id' => $branch->id, + 'patient_id' => $patient->id, + 'department_id' => $dept->id, + 'visit_id' => $visit->id, + 'type' => Appointment::TYPE_WALK_IN, + 'status' => Appointment::STATUS_IN_CONSULTATION, + 'scheduled_at' => now(), + 'checked_in_at' => now(), + 'started_at' => now(), + ]); + + $this->assertSame($employer->id, app(OrganizationResolver::class)->resolveForUser($nurse)?->id); + + $this->actingAs($nurse) + ->get(route('care.specialty.workspace', [ + 'module' => 'emergency', + 'visit' => $visit, + 'tab' => 'documents', + ])) + ->assertOk() + ->assertSee('Documents') + ->assertSee('Upload document'); + } + + public function test_nurse_can_open_emergency_documents_tab(): void + { + $this->withoutMiddleware(EnsurePlatformSession::class); + + $owner = User::create([ + 'public_id' => 'er-docs-owner', + 'name' => 'Owner', + 'email' => 'er-docs-owner@example.com', + ]); + + $nurse = User::create([ + 'public_id' => 'er-docs-nurse', + 'name' => 'Nurse', + 'email' => 'er-docs-nurse@example.com', + ]); + + $organization = Organization::create([ + 'owner_ref' => $owner->public_id, + 'name' => 'ER Clinic', + 'slug' => 'er-docs-clinic', + 'settings' => [ + 'onboarded' => true, + 'plan' => 'pro', + 'plan_expires_at' => now()->addMonth()->toIso8601String(), + 'specialty_modules' => ['emergency' => true], + ], + ]); + + Member::create([ + 'owner_ref' => $owner->public_id, + 'organization_id' => $organization->id, + 'user_ref' => $owner->public_id, + 'role' => 'hospital_admin', + ]); + + Member::create([ + 'owner_ref' => $owner->public_id, + 'organization_id' => $organization->id, + 'user_ref' => $nurse->public_id, + 'role' => 'nurse', + ]); + + $branch = Branch::create([ + 'owner_ref' => $owner->public_id, + 'organization_id' => $organization->id, + 'name' => 'Main', + 'is_active' => true, + ]); + + app(SpecialtyModuleService::class)->activate($organization, $owner->public_id, 'emergency'); + + $dept = Department::query()->where('type', 'emergency')->where('branch_id', $branch->id)->firstOrFail(); + + $patient = Patient::create([ + 'owner_ref' => $owner->public_id, + 'organization_id' => $organization->id, + 'branch_id' => $branch->id, + 'patient_number' => 'P-DOCS', + 'first_name' => 'Ama', + 'last_name' => 'Docs', + 'gender' => 'female', + 'date_of_birth' => '1990-01-01', + ]); + + $visit = Visit::create([ + 'owner_ref' => $owner->public_id, + 'organization_id' => $organization->id, + 'branch_id' => $branch->id, + 'patient_id' => $patient->id, + 'status' => Visit::STATUS_IN_PROGRESS, + 'checked_in_at' => now(), + ]); + + Appointment::create([ + 'owner_ref' => $owner->public_id, + 'organization_id' => $organization->id, + 'branch_id' => $branch->id, + 'patient_id' => $patient->id, + 'department_id' => $dept->id, + 'visit_id' => $visit->id, + 'type' => Appointment::TYPE_WALK_IN, + 'status' => Appointment::STATUS_IN_CONSULTATION, + 'scheduled_at' => now(), + 'checked_in_at' => now(), + 'started_at' => now(), + ]); + + $this->actingAs($nurse) + ->get(route('care.specialty.workspace', [ + 'module' => 'emergency', + 'visit' => $visit, + 'tab' => 'documents', + ])) + ->assertOk() + ->assertSee('Documents') + ->assertSee('Upload document'); + } + + public function test_view_only_staff_sees_documents_without_upload(): void + { + $this->withoutMiddleware(EnsurePlatformSession::class); + + $owner = User::create([ + 'public_id' => 'view-docs-owner', + 'name' => 'Owner', + 'email' => 'view-docs-owner@example.com', + ]); + + $lab = User::create([ + 'public_id' => 'view-docs-lab', + 'name' => 'Lab', + 'email' => 'view-docs-lab@example.com', + ]); + + $organization = Organization::create([ + 'owner_ref' => $owner->public_id, + 'name' => 'Cardio Clinic', + 'slug' => 'cardio-docs-clinic', + 'settings' => [ + 'onboarded' => true, + 'plan' => 'pro', + 'plan_expires_at' => now()->addMonth()->toIso8601String(), + ], + ]); + + Member::create([ + 'owner_ref' => $owner->public_id, + 'organization_id' => $organization->id, + 'user_ref' => $owner->public_id, + 'role' => 'hospital_admin', + ]); + + Member::create([ + 'owner_ref' => $owner->public_id, + 'organization_id' => $organization->id, + 'user_ref' => $lab->public_id, + 'role' => 'lab_technician', + ]); + + $branch = Branch::create([ + 'owner_ref' => $owner->public_id, + 'organization_id' => $organization->id, + 'name' => 'Main', + 'is_active' => true, + ]); + + $modules = app(SpecialtyModuleService::class); + $modules->activate($organization, $owner->public_id, 'cardiology'); + + $dept = Department::query()->where('type', 'cardiology')->where('branch_id', $branch->id)->firstOrFail(); + + $patient = Patient::create([ + 'owner_ref' => $owner->public_id, + 'organization_id' => $organization->id, + 'branch_id' => $branch->id, + 'patient_number' => 'P-VIEW-DOCS', + 'first_name' => 'Kofi', + 'last_name' => 'View', + 'gender' => 'male', + 'date_of_birth' => '1985-01-01', + ]); + + $visit = Visit::create([ + 'owner_ref' => $owner->public_id, + 'organization_id' => $organization->id, + 'branch_id' => $branch->id, + 'patient_id' => $patient->id, + 'status' => Visit::STATUS_IN_PROGRESS, + 'checked_in_at' => now(), + ]); + + Appointment::create([ + 'owner_ref' => $owner->public_id, + 'organization_id' => $organization->id, + 'branch_id' => $branch->id, + 'patient_id' => $patient->id, + 'department_id' => $dept->id, + 'visit_id' => $visit->id, + 'type' => Appointment::TYPE_WALK_IN, + 'status' => Appointment::STATUS_IN_CONSULTATION, + 'scheduled_at' => now(), + 'checked_in_at' => now(), + 'started_at' => now(), + ]); + + $labMember = Member::query()->where('user_ref', $lab->public_id)->firstOrFail(); + $this->assertSame('view', $modules->memberAccessLevel($organization, $labMember, 'cardiology')); + + $this->actingAs($lab) + ->get(route('care.specialty.workspace', [ + 'module' => 'cardiology', + 'visit' => $visit, + 'tab' => 'documents', + ])) + ->assertOk() + ->assertSee('Documents') + ->assertDontSee('Upload document'); + } +}