Fix nurse onboarding redirects and specialty Documents 404s.
Deploy Ladill Care / deploy (push) Successful in 36s

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 <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-18 19:09:06 +00:00
co-authored by Cursor
parent 656f402e8f
commit c3219a1bf1
7 changed files with 524 additions and 14 deletions
@@ -26,6 +26,10 @@ class OnboardingController extends Controller
return redirect()->route('care.dashboard'); 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', [ return view('care.onboarding.show', [
'user' => $request->user(), 'user' => $request->user(),
'timezones' => timezone_identifiers_list(), 'timezones' => timezone_identifiers_list(),
@@ -41,6 +45,10 @@ class OnboardingController extends Controller
return redirect()->route('care.dashboard'); 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()); $categoryKeys = array_keys($this->workflows->categories());
$templateKeys = array_keys($this->workflows->templates()); $templateKeys = array_keys($this->workflows->templates());
@@ -347,6 +347,7 @@ class SpecialtyModuleController extends Controller
$organization = $this->organization($request); $organization = $this->organization($request);
$member = $this->member($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->memberCanAccess($organization, $member, $module), 403);
abort_unless($modules->memberCanManage($organization, $member, $module), 403); abort_unless($modules->memberCanManage($organization, $member, $module), 403);
abort_unless($visit->organization_id === $organization->id, 404); abort_unless($visit->organization_id === $organization->id, 404);
@@ -25,6 +25,11 @@ class EnsureOrganizationSetup
} }
if (! $this->organizations->isOnboarded($user)) { 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'); return redirect()->route('care.onboarding.show');
} }
+45 -8
View File
@@ -670,17 +670,54 @@ class DemoTenantSeeder
$branch = $branches[$index % count($branches)]; $branch = $branches[$index % count($branches)];
} }
Member::query()->updateOrCreate( $email = strtolower((string) $staff['email']);
[ // Prefer remapped public_id when the staff user already exists locally,
'organization_id' => $organization->id, // so reseeds do not orphan SSO sessions keyed by public_id.
'user_ref' => strtolower($staff['email']), $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, 'owner_ref' => $ownerRef,
'role' => $role, 'role' => $role,
'branch_id' => $role === 'hospital_admin' ? null : $branch?->id, '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,
]);
} }
} }
+80 -5
View File
@@ -17,9 +17,7 @@ class OrganizationResolver
{ {
public function resolveForUser(User $user): ?Organization public function resolveForUser(User $user): ?Organization
{ {
$ref = $user->ownerRef(); $member = $this->membershipFor($user);
$member = Member::where('user_ref', $ref)->first();
if ($member) { if ($member) {
$organization = Organization::query()->find($member->organization_id); $organization = Organization::query()->find($member->organization_id);
if ($organization) { 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 public function forUser(User $user): Organization
@@ -44,6 +42,40 @@ class OrganizationResolver
&& (bool) data_get($organization->settings, 'onboarded', false); && (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 public function memberFor(User $user, ?Organization $organization = null): ?Member
{ {
$organization ??= $this->resolveForUser($user); $organization ??= $this->resolveForUser($user);
@@ -51,9 +83,52 @@ class OrganizationResolver
return null; return null;
} }
return Member::where('organization_id', $organization->id) $member = Member::query()
->where('organization_id', $organization->id)
->where('user_ref', $user->ownerRef()) ->where('user_ref', $user->ownerRef())
->first(); ->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 public function ensureOwnerMember(User $user, Organization $organization): Member
@@ -88,7 +88,7 @@
<h3 class="text-sm font-semibold text-slate-900">Documents</h3> <h3 class="text-sm font-semibold text-slate-900">Documents</h3>
<p class="mt-1 text-xs text-slate-500">Specialty documents attach to the patient chart for this episode.</p> <p class="mt-1 text-xs text-slate-500">Specialty documents attach to the patient chart for this episode.</p>
@if ($canManageClinical ?? $canManageSpecialty ?? true) @if ($canManageClinical ?? $canManageSpecialty ?? false)
<form method="POST" action="{{ route('care.specialty.documents.upload', ['module' => $moduleKey, 'visit' => $workspaceVisit]) }}" enctype="multipart/form-data" class="mt-4 space-y-3"> <form method="POST" action="{{ route('care.specialty.documents.upload', ['module' => $moduleKey, 'visit' => $workspaceVisit]) }}" enctype="multipart/form-data" class="mt-4 space-y-3">
@csrf @csrf
<input type="file" name="attachment" required accept=".pdf,.jpg,.jpeg,.png,.webp" class="block w-full text-sm"> <input type="file" name="attachment" required accept=".pdf,.jpg,.jpeg,.png,.webp" class="block w-full text-sm">
+384
View File
@@ -0,0 +1,384 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Appointment;
use App\Models\Branch;
use App\Models\Department;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\User;
use App\Models\Visit;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\SpecialtyModuleService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CareNurseOrgAccessTest extends TestCase
{
use RefreshDatabase;
public function test_nurse_with_email_user_ref_membership_is_not_sent_to_onboarding(): void
{
$this->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');
}
}