Recover empty Care demo tenants after onboarding races.
Deploy Ladill Care / deploy (push) Successful in 54s
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:
@@ -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));
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user