From db6d0617e9fb0d969a05c2ff86c87adcd9ffbf88 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Fri, 17 Jul 2026 21:55:30 +0000 Subject: [PATCH] Enable all specialty modules with demo traffic for Pro/Enterprise demos. Free and real onboarding stay opt-in; Pro/Enterprise demos activate every catalog specialty and seed waiting appointments so module pages are populated. Co-authored-by: Cursor --- app/Services/Care/DemoTenantSeeder.php | 202 +++++++++++++++++++++++-- tests/Feature/DemoSeedCommandTest.php | 47 ++++++ 2 files changed, 235 insertions(+), 14 deletions(-) diff --git a/app/Services/Care/DemoTenantSeeder.php b/app/Services/Care/DemoTenantSeeder.php index 006707b..2082897 100644 --- a/app/Services/Care/DemoTenantSeeder.php +++ b/app/Services/Care/DemoTenantSeeder.php @@ -110,6 +110,7 @@ class DemoTenantSeeder $departments = $this->seedDepartments($branches, $ownerRef, $volumes); if (in_array($plan, ['pro', 'enterprise'], true)) { $this->seedSpecialtyModules($organization, $ownerRef); + $departments = $this->departmentsByBranch($branches, $ownerRef); } $practitioners = $this->seedPractitioners($organization, $branches, $departments, $ownerRef, $volumes); $patients = $this->seedPatients($organization, $branches, $ownerRef, $volumes); @@ -122,6 +123,15 @@ class DemoTenantSeeder $ownerRef, $volumes, ); + if (in_array($plan, ['pro', 'enterprise'], true)) { + $appointmentCount += $this->seedSpecialtyDemoData( + $organization->fresh(), + $branches, + $practitioners, + $patients, + $ownerRef, + ); + } if ($volumes['paid_modules']) { $this->seedPaidModules( @@ -173,13 +183,17 @@ class DemoTenantSeeder return; } + $contexts = [ + CareQueueContexts::CONSULTATION, + CareQueueContexts::PHARMACY, + CareQueueContexts::LABORATORY, + CareQueueContexts::BILLING, + CareQueueContexts::IMAGING, + ...array_keys(config('care.specialty_modules', [])), + ]; + foreach ($branches as $branch) { - foreach ([ - CareQueueContexts::CONSULTATION, - CareQueueContexts::PHARMACY, - CareQueueContexts::LABORATORY, - CareQueueContexts::BILLING, - ] as $context) { + foreach ($contexts as $context) { $bridge->syncMissingTickets($organization, $context, (int) $branch->id, 100); } } @@ -395,10 +409,11 @@ class DemoTenantSeeder 'plan_expires_at' => now()->addYear()->toIso8601String(), 'billed_branches' => max(1, $billedBranches), 'demo' => true, - // Pro/Enterprise demos: Queue on natural role pages + dentistry specialty module. + // Pro/Enterprise demos: Queue on natural role pages + every specialty module. + // Free / non-demo tenants leave specialty_modules empty (opt-in via Settings). 'queue_integration_enabled' => in_array($plan, ['pro', 'enterprise'], true), 'specialty_modules' => in_array($plan, ['pro', 'enterprise'], true) - ? ['dentistry' => true] + ? $this->allSpecialtyFlagsEnabled() : [], // Demo seed uses the legacy clinical path. Explicitly clear leftover // onboarding rollout so a failed race that created a real workflow @@ -543,14 +558,30 @@ class DemoTenantSeeder return $byBranch; } + /** + * @return array + */ + private function allSpecialtyFlagsEnabled(): array + { + $flags = []; + foreach (array_keys(config('care.specialty_modules', [])) as $key) { + $flags[$key] = true; + } + + return $flags; + } + private function seedSpecialtyModules(Organization $organization, string $ownerRef): void { - try { - // Activates dentistry (depts + stubs). When queue_integration_enabled and - // QUEUE_API_* are configured, SpecialtyModuleService creates real Queue counters. - app(SpecialtyModuleService::class)->activate($organization->fresh(), $ownerRef, 'dentistry'); - } catch (\Throwable) { - // Demo seed should not fail if Queue API is unreachable; settings flag remains. + $service = app(SpecialtyModuleService::class); + foreach (array_keys($service->catalog()) as $key) { + try { + // Activates depts + stubs. When queue_integration_enabled and QUEUE_API_* + // are configured, SpecialtyModuleService creates real Queue counters. + $service->activate($organization->fresh(), $ownerRef, $key); + } catch (\Throwable) { + // Demo seed should not fail if Queue API is unreachable; settings flag remains. + } } if (data_get($organization->fresh()->settings, 'queue_integration_enabled')) { @@ -562,6 +593,149 @@ class DemoTenantSeeder } } + /** + * @param list $branches + * @return array> keyed by branch id + */ + private function departmentsByBranch(array $branches, string $ownerRef): array + { + $byBranch = []; + foreach ($branches as $branch) { + $byBranch[$branch->id] = Department::withTrashed() + ->owned($ownerRef) + ->where('branch_id', $branch->id) + ->where('is_active', true) + ->orderBy('id') + ->get() + ->all(); + } + + return $byBranch; + } + + /** + * Waiting / completed specialty appointments so each module page has demo traffic. + * + * @param list $branches + * @param list $practitioners + * @param list $patients + */ + private function seedSpecialtyDemoData( + Organization $organization, + array $branches, + array $practitioners, + array $patients, + string $ownerRef, + ): int { + if ($branches === [] || $patients === [] || $practitioners === []) { + return 0; + } + + $reasons = [ + 'dentistry' => ['Toothache', 'Dental cleaning', 'Extraction review'], + 'ophthalmology' => ['Blurry vision', 'Eye exam', 'Contact lens fitting'], + 'physiotherapy' => ['Back pain rehab', 'Post-injury physio', 'Mobility session'], + 'maternity' => ['Antenatal visit', 'Pregnancy check', 'Postnatal review'], + 'radiology' => ['X-ray referral', 'Ultrasound', 'Imaging review'], + ]; + + $count = 0; + $catalog = config('care.specialty_modules', []); + foreach ($catalog as $key => $definition) { + $type = (string) ($definition['department_type'] ?? ''); + if ($type === '') { + continue; + } + + $moduleReasons = $reasons[$key] ?? ['Specialty visit']; + + foreach ($branches as $branchIndex => $branch) { + $department = Department::owned($ownerRef) + ->where('branch_id', $branch->id) + ->where('type', $type) + ->where('is_active', true) + ->first(); + if (! $department) { + continue; + } + + $practitioner = Practitioner::withTrashed()->updateOrCreate( + [ + 'organization_id' => $organization->id, + 'user_ref' => sprintf('demo-specialty-%s-%s', $key, $branch->id), + ], + [ + 'owner_ref' => $ownerRef, + 'branch_id' => $branch->id, + 'department_id' => $department->id, + 'member_id' => null, + 'name' => ($definition['label'] ?? ucfirst($key)).' Clinic', + 'specialty' => (string) ($definition['label'] ?? $key), + 'room' => ($definition['label'] ?? 'Specialty').' Bay', + 'is_active' => true, + 'deleted_at' => null, + ], + ); + + foreach ([Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN, Appointment::STATUS_COMPLETED] as $slot => $status) { + $patient = $patients[($branchIndex + $slot + $count) % count($patients)]; + $scheduledAt = now()->subHours(($branchIndex * 3) + $slot + 1); + $token = "specialty|{$key}|{$branch->id}|{$slot}"; + + $visit = null; + if (in_array($status, [Appointment::STATUS_CHECKED_IN, Appointment::STATUS_WAITING, Appointment::STATUS_COMPLETED], true)) { + $visit = Visit::withTrashed()->updateOrCreate( + ['uuid' => $this->demoUuid("visit|{$ownerRef}|{$token}")], + [ + 'owner_ref' => $ownerRef, + 'organization_id' => $organization->id, + 'branch_id' => $branch->id, + 'patient_id' => $patient->id, + 'status' => $status === Appointment::STATUS_COMPLETED + ? Visit::STATUS_COMPLETED + : Visit::STATUS_IN_PROGRESS, + 'checked_in_at' => $scheduledAt->copy()->addMinutes(5), + 'completed_at' => $status === Appointment::STATUS_COMPLETED + ? $scheduledAt->copy()->addHour() + : null, + 'checked_in_by' => $ownerRef, + 'deleted_at' => null, + ], + ); + } + + Appointment::withTrashed()->updateOrCreate( + ['uuid' => $this->demoUuid("appt|{$ownerRef}|{$token}")], + [ + 'owner_ref' => $ownerRef, + 'organization_id' => $organization->id, + 'branch_id' => $branch->id, + 'patient_id' => $patient->id, + 'practitioner_id' => $practitioner->id, + 'department_id' => $department->id, + 'visit_id' => $visit?->id, + 'type' => Appointment::TYPE_SCHEDULED, + 'status' => $status, + 'scheduled_at' => $scheduledAt, + 'checked_in_at' => $visit?->checked_in_at, + 'completed_at' => $status === Appointment::STATUS_COMPLETED + ? $scheduledAt->copy()->addHour() + : null, + 'cancelled_at' => null, + 'queue_position' => $slot + 1, + 'reason' => $moduleReasons[$slot % count($moduleReasons)], + 'created_by' => $ownerRef, + 'deleted_at' => null, + ], + ); + $count++; + } + } + } + + return $count; + } + /** * @param list $branches * @param array> $departments diff --git a/tests/Feature/DemoSeedCommandTest.php b/tests/Feature/DemoSeedCommandTest.php index b1901ea..b48f99f 100644 --- a/tests/Feature/DemoSeedCommandTest.php +++ b/tests/Feature/DemoSeedCommandTest.php @@ -84,6 +84,53 @@ class DemoSeedCommandTest extends TestCase $this->assertGreaterThan(0, Drug::query()->where('owner_ref', $user->public_id)->count()); $this->assertGreaterThan(0, Prescription::query()->where('owner_ref', $user->public_id)->count()); $this->assertGreaterThan(0, Bill::query()->where('owner_ref', $user->public_id)->count()); + + $catalog = array_keys(config('care.specialty_modules', [])); + $this->assertNotEmpty($catalog); + foreach ($catalog as $key) { + $this->assertTrue( + (bool) data_get($org->settings, "specialty_modules.{$key}"), + "Expected specialty module [{$key}] enabled for pro demo" + ); + $type = config("care.specialty_modules.{$key}.department_type"); + $this->assertGreaterThan( + 0, + \App\Models\Department::query() + ->where('owner_ref', $user->public_id) + ->where('type', $type) + ->where('is_active', true) + ->count(), + "Expected active departments for [{$key}]" + ); + $this->assertGreaterThan( + 0, + Appointment::query() + ->where('owner_ref', $user->public_id) + ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) + ->whereHas('department', fn ($q) => $q->where('type', $type)) + ->count(), + "Expected waiting specialty appointments for [{$key}]" + ); + } + } + + public function test_free_demo_does_not_enable_specialty_modules(): void + { + $user = User::create([ + 'public_id' => 'demo-free-specialty-id', + 'name' => 'Ladill Demo (Free)', + 'email' => 'demo-free@ladill.com', + ]); + + $this->artisan('demo:seed', [ + 'identity' => 'demo-free@ladill.com', + '--plan' => 'free', + ])->assertSuccessful(); + + $org = Organization::query()->where('owner_ref', $user->public_id)->firstOrFail(); + foreach (array_keys(config('care.specialty_modules', [])) as $key) { + $this->assertFalse((bool) data_get($org->settings, "specialty_modules.{$key}")); + } } public function test_enterprise_seeds_assessments_and_more_branches(): void