From c319692b3369ff388eb6d137e4dc588241b46435 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Fri, 17 Jul 2026 19:33:04 +0000 Subject: [PATCH] Create demo org shell before login redirect. Async reseed alone still left the first Care request without an onboarded org; ensure the shell synchronously, then refresh demo data afterResponse(). Co-authored-by: Cursor --- app/Services/Care/DemoLoginReseeder.php | 28 ++++++++++++++++++++++ app/Services/Care/DemoTenantSeeder.php | 23 ++++++++++++++++++ app/Services/Care/OrganizationResolver.php | 5 +++- tests/Feature/DemoLoginReseederTest.php | 20 ++++++++++++++++ 4 files changed, 75 insertions(+), 1 deletion(-) diff --git a/app/Services/Care/DemoLoginReseeder.php b/app/Services/Care/DemoLoginReseeder.php index 2b92091..9cd0ab8 100644 --- a/app/Services/Care/DemoLoginReseeder.php +++ b/app/Services/Care/DemoLoginReseeder.php @@ -11,6 +11,10 @@ use Illuminate\Support\Facades\Log; * * Only owner demo accounts (and legacy enterprise org members) trigger a full * reseed. Staff training logins never wipe mid-demo. + * + * The onboarded org shell is created synchronously before the login redirect so + * EnsureOrganizationSetup never flashes the blank onboarding form. Heavy demo + * data still refreshes afterResponse(). */ class DemoLoginReseeder { @@ -38,6 +42,30 @@ class DemoLoginReseeder $lockKey = 'demo-reseed:care:'.$seedUser->public_id; if (! Cache::add($lockKey, 1, 120)) { + // Another login is already reseeding — still guarantee the shell exists. + try { + app(DemoTenantSeeder::class)->ensureOnboardedShell($seedUser, $plan); + } catch (\Throwable $e) { + Log::warning('demo_care_login_shell_failed', [ + 'user_id' => $seedUser->id, + 'plan' => $plan, + 'error' => $e->getMessage(), + ]); + } + + return; + } + + try { + app(DemoTenantSeeder::class)->ensureOnboardedShell($seedUser, $plan); + } catch (\Throwable $e) { + Cache::forget($lockKey); + Log::warning('demo_care_login_shell_failed', [ + 'user_id' => $seedUser->id, + 'plan' => $plan, + 'error' => $e->getMessage(), + ]); + return; } diff --git a/app/Services/Care/DemoTenantSeeder.php b/app/Services/Care/DemoTenantSeeder.php index 45980b5..11cb379 100644 --- a/app/Services/Care/DemoTenantSeeder.php +++ b/app/Services/Care/DemoTenantSeeder.php @@ -91,8 +91,13 @@ class DemoTenantSeeder $ownerRef = $user->ownerRef(); $volumes = $this->volumes($plan); + // Always materialize the onboarded org shell before any wipe so concurrent + // SSO redirects never land on the blank onboarding form. + $this->ensureOnboardedShell($user, $plan); + if ($reset) { DB::transaction(fn () => $this->resetTenant($ownerRef)); + $this->ensureOnboardedShell($user, $plan); } return DB::transaction(function () use ($user, $ownerRef, $plan, $volumes) { @@ -182,6 +187,24 @@ class DemoTenantSeeder } } + /** + * Ensure the demo tenant has an onboarded organization + owner member. + * Safe to call on the SSO request path before redirecting. + */ + public function ensureOnboardedShell(User $user, string $plan): Organization + { + $plan = $this->normalizePlan($plan); + $ownerRef = $user->ownerRef(); + $volumes = $this->volumes($plan); + + return DB::transaction(function () use ($user, $ownerRef, $plan, $volumes) { + $organization = $this->upsertOrganization($user, $ownerRef, $plan, $volumes['branches']); + $this->upsertOwnerMember($organization, $ownerRef); + + return $organization; + }); + } + public function resetTenant(string $ownerRef): void { $orgIds = DB::table('care_organizations')->where('owner_ref', $ownerRef)->pluck('id'); diff --git a/app/Services/Care/OrganizationResolver.php b/app/Services/Care/OrganizationResolver.php index b07fc28..225e1c5 100644 --- a/app/Services/Care/OrganizationResolver.php +++ b/app/Services/Care/OrganizationResolver.php @@ -17,7 +17,10 @@ class OrganizationResolver $member = Member::where('user_ref', $ref)->first(); if ($member) { - return Organization::find($member->organization_id); + $organization = Organization::query()->find($member->organization_id); + if ($organization) { + return $organization; + } } return Organization::owned($ref)->first(); diff --git a/tests/Feature/DemoLoginReseederTest.php b/tests/Feature/DemoLoginReseederTest.php index c2595e8..4e454f0 100644 --- a/tests/Feature/DemoLoginReseederTest.php +++ b/tests/Feature/DemoLoginReseederTest.php @@ -34,6 +34,10 @@ class DemoLoginReseederTest extends TestCase ]); $seeder = $this->mock(DemoTenantSeeder::class); + $seeder->shouldReceive('ensureOnboardedShell') + ->once() + ->withArgs(fn (User $u, string $plan) => $u->is($user) && $plan === 'free') + ->andReturn(new \App\Models\Organization); $seeder->shouldReceive('seed') ->once() ->withArgs(fn (User $u, string $plan, bool $reset) => $u->is($user) && $plan === 'free' && $reset === true) @@ -53,6 +57,7 @@ class DemoLoginReseederTest extends TestCase ]); $seeder = $this->mock(DemoTenantSeeder::class); + $seeder->shouldReceive('ensureOnboardedShell')->never(); $seeder->shouldReceive('seed')->never(); app(DemoLoginReseeder::class)->maybeResetAfterLogin($user); @@ -70,6 +75,7 @@ class DemoLoginReseederTest extends TestCase ]); $seeder = $this->mock(DemoTenantSeeder::class); + $seeder->shouldReceive('ensureOnboardedShell')->never(); $seeder->shouldReceive('seed')->never(); app(DemoLoginReseeder::class)->maybeResetAfterLogin($user); @@ -85,9 +91,23 @@ class DemoLoginReseederTest extends TestCase ]); $seeder = $this->mock(DemoTenantSeeder::class); + $seeder->shouldReceive('ensureOnboardedShell')->never(); $seeder->shouldReceive('seed')->never(); app(DemoLoginReseeder::class)->maybeResetAfterLogin($user); $this->app->terminate(); } + + public function test_login_shell_is_onboarded_before_async_reseed(): void + { + $user = User::create([ + 'public_id' => 'demo-shell-pid', + 'name' => 'Ladill Demo (Pro)', + 'email' => 'demo-pro@ladill.com', + ]); + + app(DemoLoginReseeder::class)->maybeResetAfterLogin($user); + + $this->assertTrue(app(\App\Services\Care\OrganizationResolver::class)->isOnboarded($user)); + } }