Recover empty Care demo tenants after onboarding races.
Deploy Ladill Care / deploy (push) Successful in 54s

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 <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-17 21:38:21 +00:00
co-authored by Cursor
parent e172d3c5d1
commit c461430441
7 changed files with 247 additions and 55 deletions
+64 -15
View File
@@ -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', [
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));
+11 -1
View File
@@ -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()) {
+42 -8
View File
@@ -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,12 +85,7 @@ 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' => [
$settings = [
'onboarded' => true,
'facility_category' => $category,
'facility_type' => $data['facility_type'] ?? $category,
@@ -101,11 +97,40 @@ class OrganizationResolver
// 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::query()
->where('organization_id', $organization->id)
->orderBy('id')
->first();
if (! $branch) {
$branch = Branch::create([
'owner_ref' => $ref,
'organization_id' => $organization->id,
@@ -122,8 +147,17 @@ class OrganizationResolver
'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)) {
if ($registry->hasTemplate($templateKey)
&& ! FacilityWorkflow::query()->where('organization_id', $organization->id)->exists()) {
app(WorkflowTemplateInstaller::class)->install($organization, $branch, $templateKey, [
'category' => $category,
]);
@@ -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',
);
});
}
@@ -0,0 +1,62 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Repair indexes skipped when an earlier deploy created care_facility_workflows
* then failed on the too-long auto-generated composite index name.
*/
return new class extends Migration
{
public function up(): 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_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');
}
});
}
};
+27
View File
@@ -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());
}
}
+5
View File
@@ -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