From c4614304414b3349cd046d74c183540267292b5c Mon Sep 17 00:00:00 2001 From: isaacclad Date: Fri, 17 Jul 2026 21:38:21 +0000 Subject: [PATCH] Recover empty Care demo tenants after onboarding races. Demo Pro was left as an empty workflow shell after lock-timeout races; clear leftover rollout flags on seed, sync-reseed empty tenants on login, and repair missing facility workflow indexes. Co-authored-by: Cursor --- app/Services/Care/DemoLoginReseeder.php | 87 +++++++++++---- app/Services/Care/DemoTenantSeeder.php | 12 ++- app/Services/Care/OrganizationResolver.php | 102 ++++++++++++------ ...000_create_care_workflow_engine_tables.php | 7 +- ..._repair_care_facility_workflow_indexes.php | 62 +++++++++++ tests/Feature/DemoLoginReseederTest.php | 27 +++++ tests/Feature/DemoSeedCommandTest.php | 5 + 7 files changed, 247 insertions(+), 55 deletions(-) create mode 100644 database/migrations/2026_07_17_220000_repair_care_facility_workflow_indexes.php diff --git a/app/Services/Care/DemoLoginReseeder.php b/app/Services/Care/DemoLoginReseeder.php index 9cd0ab8..d14a933 100644 --- a/app/Services/Care/DemoLoginReseeder.php +++ b/app/Services/Care/DemoLoginReseeder.php @@ -2,7 +2,10 @@ namespace App\Services\Care; +use App\Models\Organization; +use App\Models\Patient; use App\Models\User; +use Illuminate\Database\QueryException; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; @@ -14,7 +17,8 @@ use Illuminate\Support\Facades\Log; * * 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(). + * data still refreshes afterResponse() when the tenant already has patients; + * empty tenants (failed prior reseed / onboarding race) seed synchronously. */ class DemoLoginReseeder { @@ -43,28 +47,33 @@ 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(), - ]); - } + $this->tryShell($seedUser, $plan); return; } - try { - app(DemoTenantSeeder::class)->ensureOnboardedShell($seedUser, $plan); - } catch (\Throwable $e) { + $organization = $this->tryShell($seedUser, $plan); + if ($organization === null) { Cache::forget($lockKey); - Log::warning('demo_care_login_shell_failed', [ - 'user_id' => $seedUser->id, - 'plan' => $plan, - 'error' => $e->getMessage(), - ]); + + return; + } + + $empty = Patient::query()->where('organization_id', $organization->id)->doesntExist(); + if ($empty) { + // Recover immediately so sales never land on an empty dashboard after + // a prior lock-timeout / onboarding race wiped or skipped demo data. + try { + $this->withLockRetry(fn () => app(DemoTenantSeeder::class)->seed($seedUser, $plan, reset: true)); + } catch (\Throwable $e) { + Log::warning('demo_care_login_reseed_failed', [ + 'user_id' => $seedUser->id, + 'plan' => $plan, + 'error' => $e->getMessage(), + ]); + } finally { + Cache::forget($lockKey); + } return; } @@ -74,7 +83,7 @@ class DemoLoginReseeder try { $user = User::query()->find($userId); if ($user) { - app(DemoTenantSeeder::class)->seed($user, $plan, reset: true); + $this->withLockRetry(fn () => app(DemoTenantSeeder::class)->seed($user, $plan, reset: true)); } } catch (\Throwable $e) { Log::warning('demo_care_login_reseed_failed', [ @@ -88,6 +97,46 @@ class DemoLoginReseeder })->afterResponse(); } + private function tryShell(User $seedUser, string $plan): ?Organization + { + try { + return $this->withLockRetry( + fn () => 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 null; + } + } + + /** + * @template T + * + * @param callable(): T $callback + * @return T + */ + private function withLockRetry(callable $callback): mixed + { + return retry(4, $callback, 150, function (\Throwable $e) { + return $e instanceof QueryException && $this->isLockWait($e); + }); + } + + private function isLockWait(QueryException $e): bool + { + $message = $e->getMessage(); + + return str_contains($message, '1205') + || str_contains($message, 'Lock wait timeout') + || str_contains($message, '1213') + || str_contains($message, 'Deadlock'); + } + private function planForEmail(?string $email): ?string { $email = strtolower(trim((string) $email)); diff --git a/app/Services/Care/DemoTenantSeeder.php b/app/Services/Care/DemoTenantSeeder.php index d452553..006707b 100644 --- a/app/Services/Care/DemoTenantSeeder.php +++ b/app/Services/Care/DemoTenantSeeder.php @@ -386,9 +386,11 @@ class DemoTenantSeeder ->where('owner_ref', $ownerRef) ->first(); + $facilityType = $plan === 'enterprise' ? 'hospital' : 'clinic'; $settings = array_merge($organization?->settings ?? [], [ 'onboarded' => true, - 'facility_type' => $plan === 'enterprise' ? 'hospital' : 'clinic', + 'facility_type' => $facilityType, + 'facility_category' => $facilityType, 'plan' => $plan, 'plan_expires_at' => now()->addYear()->toIso8601String(), 'billed_branches' => max(1, $billedBranches), @@ -398,7 +400,15 @@ class DemoTenantSeeder 'specialty_modules' => in_array($plan, ['pro', 'enterprise'], true) ? ['dentistry' => true] : [], + // Demo seed uses the legacy clinical path. Explicitly clear leftover + // onboarding rollout so a failed race that created a real workflow + // shell cannot leave an empty gated tenant. + 'rollout' => [ + CareFeatures::WORKFLOW_ENGINE => false, + CareFeatures::FINANCIAL_GATES => false, + ], ]); + unset($settings['workflow_template']); if ($organization) { if ($organization->trashed()) { diff --git a/app/Services/Care/OrganizationResolver.php b/app/Services/Care/OrganizationResolver.php index ef44669..a733a98 100644 --- a/app/Services/Care/OrganizationResolver.php +++ b/app/Services/Care/OrganizationResolver.php @@ -4,6 +4,7 @@ namespace App\Services\Care; use App\Models\Branch; use App\Models\Department; +use App\Models\FacilityWorkflow; use App\Models\Member; use App\Models\Organization; use App\Models\User; @@ -84,46 +85,79 @@ class OrganizationResolver ?? $registry->defaultTemplateForCategory($category); $modules = $registry->modulesForCategory($category); - $organization = Organization::create([ - 'owner_ref' => $ref, - 'name' => $data['organization_name'], - 'slug' => Str::slug($data['organization_name']).'-'.substr($ref, 0, 6), - 'timezone' => $data['timezone'] ?? config('app.timezone', 'UTC'), - 'settings' => [ - 'onboarded' => true, - 'facility_category' => $category, - 'facility_type' => $data['facility_type'] ?? $category, - 'modules' => $modules, - 'workflow_template' => $templateKey, - 'rollout' => [ - CareFeatures::WORKFLOW_ENGINE => $templateKey !== 'legacy_clinic', - // Financial gates require the paid billing module and are - // explicitly enabled by an administrator in Settings. - CareFeatures::FINANCIAL_GATES => false, - ], + $settings = [ + 'onboarded' => true, + 'facility_category' => $category, + 'facility_type' => $data['facility_type'] ?? $category, + 'modules' => $modules, + 'workflow_template' => $templateKey, + 'rollout' => [ + CareFeatures::WORKFLOW_ENGINE => $templateKey !== 'legacy_clinic', + // Financial gates require the paid billing module and are + // explicitly enabled by an administrator in Settings. + CareFeatures::FINANCIAL_GATES => false, ], - ]); + ]; + + // Reuse an existing owner org (e.g. demo shell that lost the race) so + // onboarding never creates a second tenant for the same owner_ref. + $organization = Organization::withTrashed()->where('owner_ref', $ref)->first(); + if ($organization) { + if ($organization->trashed()) { + $organization->restore(); + } + $organization->forceFill([ + 'name' => $data['organization_name'], + 'slug' => Str::slug($data['organization_name']).'-'.substr($ref, 0, 6), + 'timezone' => $data['timezone'] ?? config('app.timezone', 'UTC'), + 'settings' => array_merge($organization->settings ?? [], $settings), + ])->save(); + $organization = $organization->fresh(); + } else { + $organization = Organization::create([ + 'owner_ref' => $ref, + 'name' => $data['organization_name'], + 'slug' => Str::slug($data['organization_name']).'-'.substr($ref, 0, 6), + 'timezone' => $data['timezone'] ?? config('app.timezone', 'UTC'), + 'settings' => $settings, + ]); + } $this->ensureOwnerMember($user, $organization); - $branch = Branch::create([ - 'owner_ref' => $ref, - 'organization_id' => $organization->id, - 'name' => $data['branch_name'], - 'address' => $data['branch_address'] ?? null, - 'phone' => $data['branch_phone'] ?? null, - 'is_active' => true, - ]); + $branch = Branch::query() + ->where('organization_id', $organization->id) + ->orderBy('id') + ->first(); - Department::create([ - 'owner_ref' => $ref, - 'branch_id' => $branch->id, - 'name' => 'General Outpatient', - 'type' => 'outpatient', - 'is_active' => true, - ]); + if (! $branch) { + $branch = Branch::create([ + 'owner_ref' => $ref, + 'organization_id' => $organization->id, + 'name' => $data['branch_name'], + 'address' => $data['branch_address'] ?? null, + 'phone' => $data['branch_phone'] ?? null, + 'is_active' => true, + ]); - if ($registry->hasTemplate($templateKey)) { + Department::create([ + 'owner_ref' => $ref, + 'branch_id' => $branch->id, + 'name' => 'General Outpatient', + 'type' => 'outpatient', + 'is_active' => true, + ]); + } else { + $branch->forceFill([ + 'name' => $data['branch_name'], + 'address' => $data['branch_address'] ?? $branch->address, + 'phone' => $data['branch_phone'] ?? $branch->phone, + 'is_active' => true, + ])->save(); + } + + if ($registry->hasTemplate($templateKey) + && ! FacilityWorkflow::query()->where('organization_id', $organization->id)->exists()) { app(WorkflowTemplateInstaller::class)->install($organization, $branch, $templateKey, [ 'category' => $category, ]); diff --git a/database/migrations/2026_07_17_200000_create_care_workflow_engine_tables.php b/database/migrations/2026_07_17_200000_create_care_workflow_engine_tables.php index cb3dea7..d1fb9c2 100644 --- a/database/migrations/2026_07_17_200000_create_care_workflow_engine_tables.php +++ b/database/migrations/2026_07_17_200000_create_care_workflow_engine_tables.php @@ -34,7 +34,12 @@ return new class extends Migration $table->timestamps(); $table->softDeletes(); - $table->index(['organization_id', 'branch_id', 'is_active']); + // Short name: MySQL's 64-char limit rejects the auto-generated + // care_facility_workflows_organization_id_branch_id_is_active_index. + $table->index( + ['organization_id', 'branch_id', 'is_active'], + 'care_fw_org_branch_active_idx', + ); }); } diff --git a/database/migrations/2026_07_17_220000_repair_care_facility_workflow_indexes.php b/database/migrations/2026_07_17_220000_repair_care_facility_workflow_indexes.php new file mode 100644 index 0000000..0869d87 --- /dev/null +++ b/database/migrations/2026_07_17_220000_repair_care_facility_workflow_indexes.php @@ -0,0 +1,62 @@ +mapWithKeys(fn (array $index) => [($index['name'] ?? '') => $index]); + + Schema::table('care_facility_workflows', function (Blueprint $table) use ($indexes) { + if (! $indexes->has('care_facility_workflows_uuid_unique')) { + $table->unique('uuid', 'care_facility_workflows_uuid_unique'); + } + + if (! $indexes->has('care_facility_workflows_owner_ref_index')) { + $table->index('owner_ref', 'care_facility_workflows_owner_ref_index'); + } + + if (! $indexes->has('care_fw_org_branch_active_idx') + && ! $indexes->has('care_facility_workflows_organization_id_branch_id_is_active_index')) { + $table->index( + ['organization_id', 'branch_id', 'is_active'], + 'care_fw_org_branch_active_idx', + ); + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('care_facility_workflows')) { + return; + } + + $indexes = collect(Schema::getIndexes('care_facility_workflows')) + ->mapWithKeys(fn (array $index) => [($index['name'] ?? '') => $index]); + + Schema::table('care_facility_workflows', function (Blueprint $table) use ($indexes) { + if ($indexes->has('care_fw_org_branch_active_idx')) { + $table->dropIndex('care_fw_org_branch_active_idx'); + } + if ($indexes->has('care_facility_workflows_owner_ref_index')) { + $table->dropIndex('care_facility_workflows_owner_ref_index'); + } + if ($indexes->has('care_facility_workflows_uuid_unique')) { + $table->dropUnique('care_facility_workflows_uuid_unique'); + } + }); + } +}; diff --git a/tests/Feature/DemoLoginReseederTest.php b/tests/Feature/DemoLoginReseederTest.php index 4e454f0..e2b74aa 100644 --- a/tests/Feature/DemoLoginReseederTest.php +++ b/tests/Feature/DemoLoginReseederTest.php @@ -110,4 +110,31 @@ class DemoLoginReseederTest extends TestCase $this->assertTrue(app(\App\Services\Care\OrganizationResolver::class)->isOnboarded($user)); } + + public function test_empty_demo_tenant_seeds_synchronously(): void + { + $user = User::create([ + 'public_id' => 'demo-empty-pid', + 'name' => 'Ladill Demo (Pro)', + 'email' => 'demo-pro@ladill.com', + ]); + + $org = \App\Models\Organization::create([ + 'owner_ref' => $user->public_id, + 'name' => 'Empty Shell', + 'slug' => 'empty-shell', + 'timezone' => 'UTC', + 'settings' => [ + 'onboarded' => true, + 'rollout' => ['workflow_engine' => true], + ], + ]); + + app(DemoLoginReseeder::class)->maybeResetAfterLogin($user); + + $org->refresh(); + $this->assertTrue((bool) data_get($org->settings, 'demo')); + $this->assertFalse((bool) data_get($org->settings, 'rollout.workflow_engine')); + $this->assertGreaterThan(0, \App\Models\Patient::query()->where('organization_id', $org->id)->count()); + } } diff --git a/tests/Feature/DemoSeedCommandTest.php b/tests/Feature/DemoSeedCommandTest.php index a592477..b1901ea 100644 --- a/tests/Feature/DemoSeedCommandTest.php +++ b/tests/Feature/DemoSeedCommandTest.php @@ -132,6 +132,11 @@ class DemoSeedCommandTest extends TestCase $this->assertSame($appointments, Appointment::query()->where('owner_ref', $user->public_id)->count()); $this->assertSame($orgs, Organization::query()->where('owner_ref', $user->public_id)->count()); $this->assertSame(1, Branch::query()->where('owner_ref', $user->public_id)->count()); + + $organization = Organization::query()->where('owner_ref', $user->public_id)->first(); + $this->assertTrue((bool) data_get($organization?->settings, 'demo')); + $this->assertFalse((bool) data_get($organization?->settings, 'rollout.workflow_engine')); + $this->assertFalse((bool) data_get($organization?->settings, 'rollout.financial_gates')); } public function test_reset_wipes_only_tenant_scoped_data_then_reseeds(): void