From 0bdf435328ad1ec8bd7e6302fb7194fc3d80f5ea Mon Sep 17 00:00:00 2001 From: isaacclad Date: Fri, 17 Jul 2026 10:03:15 +0000 Subject: [PATCH] Add Frontdesk demo:seed for visitor walkthroughs. Seed orgs, branches, kiosks, hosts, and visits with plan-aware caps and reset. Co-authored-by: Cursor --- app/Console/Commands/DemoSeedCommand.php | 396 +++++++++++++++++++++++ tests/Feature/DemoSeedCommandTest.php | 106 ++++++ 2 files changed, 502 insertions(+) create mode 100644 app/Console/Commands/DemoSeedCommand.php create mode 100644 tests/Feature/DemoSeedCommandTest.php diff --git a/app/Console/Commands/DemoSeedCommand.php b/app/Console/Commands/DemoSeedCommand.php new file mode 100644 index 0000000..d868a52 --- /dev/null +++ b/app/Console/Commands/DemoSeedCommand.php @@ -0,0 +1,396 @@ +option('plan')); + if (! in_array($plan, ['free', 'pro', 'enterprise'], true)) { + $this->error('Plan must be free, pro, or enterprise.'); + + return self::FAILURE; + } + + $user = $this->resolveUser(); + if (! $user) { + return self::FAILURE; + } + + if ($this->option('reset')) { + $this->resetDemoData($user); + $this->info('Reset demo data for '.$user->email); + } + + $organization = $this->ensureOrganization($user, $plan); + + if ($this->hasDemoVisits($organization) && ! $this->option('reset')) { + $this->info('Demo data already present; plan updated. Use --reset to reseed.'); + $this->printSummary($user, $organization, $plans); + + return self::SUCCESS; + } + + $this->seedDomain($user, $organization, $plan); + $this->printSummary($user, $organization->fresh(), $plans); + + return self::SUCCESS; + } + + private function resolveUser(): ?User + { + $identity = trim((string) $this->argument('identity')); + $publicIdOpt = trim((string) ($this->option('public-id') ?: '')); + $emailOpt = trim((string) ($this->option('email') ?: '')); + $nameOpt = trim((string) ($this->option('name') ?: '')); + + $user = User::query() + ->where('email', $identity) + ->orWhere('public_id', $identity) + ->first(); + + if ($user) { + return $user; + } + + $publicId = $publicIdOpt !== '' ? $publicIdOpt : (Str::isUuid($identity) ? $identity : ''); + $email = $emailOpt !== '' ? $emailOpt : (filter_var($identity, FILTER_VALIDATE_EMAIL) ? $identity : ''); + + if ($publicId === '' || $email === '') { + $this->error('Local user not found for "'.$identity.'". Pass --public-id and --email to create a mirror.'); + + return null; + } + + return User::query()->updateOrCreate( + ['public_id' => $publicId], + [ + 'email' => $email, + 'name' => $nameOpt !== '' ? $nameOpt : Str::before($email, '@'), + 'email_verified_at' => now(), + ], + ); + } + + private function ensureOrganization(User $user, string $plan): Organization + { + $paid = $plan !== 'free'; + $branchCount = $paid ? ($plan === 'enterprise' ? 4 : 3) : 1; + + $settings = [ + 'onboarded' => true, + 'plan' => $plan, + 'badge_expiry_hours' => 8, + 'kiosk_reset_seconds' => 30, + 'notification_channels' => ['email'], + 'visitor_policy' => 'standard', + ]; + + if ($paid) { + $settings['plan_expires_at'] = now()->addYear()->toIso8601String(); + $settings['billed_branches'] = $branchCount; + $settings['auto_renew'] = false; + $settings['billing_method'] = 'wallet_monthly'; + } else { + $settings['plan_expires_at'] = null; + $settings['billed_branches'] = 0; + } + + $org = Organization::query()->withTrashed()->where('owner_ref', $user->public_id)->first(); + if (! $org) { + $org = new Organization([ + 'owner_ref' => $user->public_id, + 'slug' => 'demo-frontdesk-'.substr(md5($user->public_id), 0, 8), + ]); + } elseif ($org->trashed()) { + $org->restore(); + } + + $org->fill([ + 'name' => $plan === 'enterprise' ? 'Accra Healthcare Group Frontdesk' : 'Ladill Demo Frontdesk', + 'timezone' => 'Africa/Accra', + 'settings' => array_merge((array) ($org->settings ?? []), $settings), + ]); + $org->save(); + + Member::query()->updateOrCreate( + [ + 'organization_id' => $org->id, + 'user_ref' => $user->public_id, + ], + [ + 'owner_ref' => $user->public_id, + 'role' => 'org_admin', + 'branch_id' => null, + ], + ); + + return $org->fresh(); + } + + private function hasDemoVisits(Organization $organization): bool + { + return Visit::query() + ->where('organization_id', $organization->id) + ->where('notes', 'demo:seed') + ->exists(); + } + + private function resetDemoData(User $user): void + { + $ownerRef = $user->public_id; + + DB::transaction(function () use ($ownerRef, $user) { + $orgIds = Organization::withTrashed()->where('owner_ref', $ownerRef)->pluck('id'); + if ($orgIds->isEmpty()) { + return; + } + + $deviceIds = Device::withTrashed()->where('owner_ref', $ownerRef)->pluck('id'); + $visitIds = Visit::withTrashed()->where('owner_ref', $ownerRef)->pluck('id'); + $employeeIds = Employee::withTrashed()->where('owner_ref', $ownerRef)->pluck('id'); + + if ($deviceIds->isNotEmpty() || $visitIds->isNotEmpty()) { + OfflineCheckIn::query() + ->where(function ($q) use ($deviceIds, $visitIds) { + if ($deviceIds->isNotEmpty()) { + $q->orWhereIn('device_id', $deviceIds); + } + if ($visitIds->isNotEmpty()) { + $q->orWhereIn('visit_id', $visitIds); + } + }) + ->delete(); + } + + if ($employeeIds->isNotEmpty()) { + EmployeePresence::query()->whereIn('employee_id', $employeeIds)->delete(); + } + + EmployeePresenceEvent::query()->where('owner_ref', $ownerRef)->delete(); + Employee::withTrashed()->where('owner_ref', $ownerRef)->forceDelete(); + NotificationUsage::query()->whereIn('organization_id', $orgIds)->delete(); + MessagingCredential::query()->where('owner_ref', $ownerRef)->delete(); + WebhookEndpoint::query()->where('owner_ref', $ownerRef)->delete(); + AuditLog::query()->where('owner_ref', $ownerRef)->delete(); + WatchlistEntry::withTrashed()->where('owner_ref', $ownerRef)->forceDelete(); + Visit::withTrashed()->where('owner_ref', $ownerRef)->forceDelete(); + Device::withTrashed()->where('owner_ref', $ownerRef)->forceDelete(); + Visitor::withTrashed()->where('owner_ref', $ownerRef)->forceDelete(); + Host::withTrashed()->where('owner_ref', $ownerRef)->forceDelete(); + Member::query()->where('owner_ref', $ownerRef)->delete(); + ReceptionDesk::withTrashed()->where('owner_ref', $ownerRef)->forceDelete(); + Building::withTrashed()->where('owner_ref', $ownerRef)->forceDelete(); + Branch::withTrashed()->where('owner_ref', $ownerRef)->forceDelete(); + Organization::withTrashed()->where('owner_ref', $ownerRef)->forceDelete(); + + DB::table('notifications') + ->where('notifiable_type', User::class) + ->where('notifiable_id', $user->id) + ->delete(); + }); + } + + private function seedDomain(User $user, Organization $organization, string $plan): void + { + $ownerRef = $user->public_id; + $paid = $plan !== 'free'; + $branchCount = $paid ? ($plan === 'enterprise' ? 4 : 3) : 1; + $visitTarget = $paid ? ($plan === 'enterprise' ? 120 : 80) : 40; + $hostCount = $paid ? 6 : 3; + $visitorCount = $paid ? 30 : 15; + + $branches = []; + $names = ['HQ', 'East Legon', 'Airport', 'Tema']; + for ($i = 0; $i < $branchCount; $i++) { + $branches[] = Branch::query()->create([ + 'owner_ref' => $ownerRef, + 'organization_id' => $organization->id, + 'name' => $names[$i] ?? ('Branch '.($i + 1)), + 'code' => 'BR'.($i + 1), + 'address' => 'Accra branch address '.($i + 1), + 'phone' => '+23330200000'.($i + 1), + 'is_active' => true, + ]); + } + + $hosts = []; + $desks = []; + foreach ($branches as $index => $branch) { + $building = Building::query()->create([ + 'owner_ref' => $ownerRef, + 'branch_id' => $branch->id, + 'name' => $branch->name.' Building', + 'floor_count' => 3, + ]); + + $desk = ReceptionDesk::query()->create([ + 'owner_ref' => $ownerRef, + 'building_id' => $building->id, + 'name' => $branch->name.' Desk', + 'location' => 'Lobby', + 'is_active' => true, + ]); + $desks[$branch->id] = $desk; + + $kioskLimit = $paid ? 2 : 1; + if ($index === 0 || $paid) { + for ($k = 1; $k <= ($index === 0 ? $kioskLimit : 1); $k++) { + if (! $paid && Device::query()->where('organization_id', $organization->id)->where('type', 'kiosk')->count() >= 1) { + break; + } + + Device::query()->create([ + 'owner_ref' => $ownerRef, + 'organization_id' => $organization->id, + 'branch_id' => $branch->id, + 'reception_desk_id' => $desk->id, + 'name' => $branch->name.' Kiosk '.$k, + 'type' => 'kiosk', + 'status' => 'online', + 'device_token' => hash('sha256', $ownerRef.'|'.$branch->id.'|'.$k), + 'config' => ['mode' => 'self_service', 'demo' => true], + 'last_online_at' => now(), + ]); + } + } + } + + for ($h = 1; $h <= $hostCount; $h++) { + $branch = $branches[($h - 1) % count($branches)]; + $hosts[] = Host::query()->create([ + 'owner_ref' => $ownerRef, + 'organization_id' => $organization->id, + 'branch_id' => $branch->id, + 'name' => 'Host '.$h, + 'department' => $h % 2 === 0 ? 'Operations' : 'People', + 'office' => 'Office '.$h, + 'phone' => '+23324'.str_pad((string) $h, 7, '0', STR_PAD_LEFT), + 'email' => 'host'.$h.'@demo.ladill.com', + 'extension' => (string) (100 + $h), + 'is_available' => true, + ]); + } + + $visitors = []; + for ($v = 1; $v <= $visitorCount; $v++) { + $visitors[] = Visitor::query()->create([ + 'owner_ref' => $ownerRef, + 'organization_id' => $organization->id, + 'full_name' => 'Visitor '.$v, + 'company' => 'Guest Co '.$v, + 'phone' => '+23355'.str_pad((string) $v, 7, '0', STR_PAD_LEFT), + 'email' => 'visitor'.$v.'@demo.ladill.com', + 'watchlist_status' => Visitor::WATCHLIST_ALLOWED, + 'visit_count' => 0, + 'is_frequent' => $v <= 3, + ]); + } + + if ($paid && isset($visitors[0])) { + WatchlistEntry::query()->create([ + 'owner_ref' => $ownerRef, + 'organization_id' => $organization->id, + 'visitor_id' => $visitors[0]->id, + 'full_name' => $visitors[0]->full_name, + 'company' => $visitors[0]->company, + 'status' => Visitor::WATCHLIST_REQUIRES_APPROVAL, + 'reason' => 'Demo watchlist entry', + 'created_by' => $ownerRef, + ]); + } + + $statuses = [ + Visit::STATUS_CHECKED_OUT, + Visit::STATUS_CHECKED_IN, + Visit::STATUS_WAITING, + Visit::STATUS_EXPECTED, + Visit::STATUS_SCHEDULED, + ]; + + for ($i = 1; $i <= $visitTarget; $i++) { + $branch = $branches[($i - 1) % count($branches)]; + $host = $hosts[($i - 1) % count($hosts)]; + $visitor = $visitors[($i - 1) % count($visitors)]; + $status = $statuses[($i - 1) % count($statuses)]; + $checkedIn = in_array($status, [Visit::STATUS_CHECKED_IN, Visit::STATUS_CHECKED_OUT, Visit::STATUS_WAITING], true) + ? now()->subDays($i % 20)->subHours($i % 8) + : null; + + Visit::query()->create([ + 'owner_ref' => $ownerRef, + 'organization_id' => $organization->id, + 'branch_id' => $branch->id, + 'reception_desk_id' => $desks[$branch->id]->id ?? null, + 'visitor_id' => $visitor->id, + 'host_id' => $host->id, + 'visitor_type' => 'guest', + 'status' => $status, + 'purpose' => 'Demo visit '.$i, + 'expected_duration_minutes' => 60, + 'scheduled_at' => now()->subDays($i % 25), + 'checked_in_at' => $checkedIn, + 'checked_out_at' => $status === Visit::STATUS_CHECKED_OUT ? ($checkedIn?->copy()->addHour()) : null, + 'badge_expires_at' => $checkedIn?->copy()->addHours(8), + 'notes' => 'demo:seed', + 'source' => 'demo', + ]); + } + } + + private function printSummary(User $user, Organization $organization, PlanService $plans): void + { + $this->table( + ['Field', 'Value'], + [ + ['email', $user->email], + ['public_id', $user->public_id], + ['org', $organization->name], + ['effective_plan', $plans->planKey($organization)], + ['branches', (string) Branch::query()->where('organization_id', $organization->id)->count()], + ['kiosks', (string) Device::query()->where('organization_id', $organization->id)->where('type', 'kiosk')->count()], + ['hosts', (string) Host::query()->where('organization_id', $organization->id)->count()], + ['visitors', (string) Visitor::query()->where('organization_id', $organization->id)->count()], + ['visits', (string) Visit::query()->where('organization_id', $organization->id)->count()], + ], + ); + } +} diff --git a/tests/Feature/DemoSeedCommandTest.php b/tests/Feature/DemoSeedCommandTest.php new file mode 100644 index 0000000..adff11d --- /dev/null +++ b/tests/Feature/DemoSeedCommandTest.php @@ -0,0 +1,106 @@ + (string) Str::uuid(), + 'name' => 'Demo Free', + 'email' => 'demo-free@ladill.com', + ]); + + $this->artisan('demo:seed', [ + 'identity' => $user->email, + '--plan' => 'free', + ])->assertSuccessful(); + + $org = Organization::query()->where('owner_ref', $user->public_id)->firstOrFail(); + $this->assertSame('free', app(PlanService::class)->planKey($org)); + $this->assertTrue((bool) ($org->settings['onboarded'] ?? false)); + $this->assertSame(1, Branch::query()->where('organization_id', $org->id)->count()); + $this->assertSame(1, Device::query()->where('organization_id', $org->id)->where('type', 'kiosk')->count()); + $this->assertGreaterThan(0, Host::query()->where('organization_id', $org->id)->count()); + $this->assertGreaterThan(0, Visitor::query()->where('organization_id', $org->id)->count()); + $this->assertSame(40, Visit::query()->where('organization_id', $org->id)->count()); + } + + public function test_pro_plan_uses_org_settings_and_multi_branch(): void + { + $user = User::create([ + 'public_id' => (string) Str::uuid(), + 'name' => 'Demo Pro', + 'email' => 'demo-pro@ladill.com', + ]); + + $this->artisan('demo:seed', [ + 'identity' => $user->public_id, + '--plan' => 'pro', + ])->assertSuccessful(); + + $org = Organization::query()->where('owner_ref', $user->public_id)->firstOrFail(); + $this->assertSame('pro', app(PlanService::class)->planKey($org)); + $this->assertNotEmpty($org->settings['plan_expires_at'] ?? null); + $this->assertGreaterThanOrEqual(3, (int) ($org->settings['billed_branches'] ?? 0)); + $this->assertGreaterThan(1, Branch::query()->where('organization_id', $org->id)->count()); + $this->assertGreaterThan(1, Device::query()->where('organization_id', $org->id)->where('type', 'kiosk')->count()); + $this->assertGreaterThan(40, Visit::query()->where('organization_id', $org->id)->count()); + $this->assertGreaterThan(0, WatchlistEntry::query()->where('organization_id', $org->id)->count()); + } + + public function test_idempotent_and_reset_only_touches_owner_data(): void + { + $user = User::create([ + 'public_id' => (string) Str::uuid(), + 'name' => 'Demo Enterprise', + 'email' => 'demo-enterprise@ladill.com', + ]); + $other = User::create([ + 'public_id' => (string) Str::uuid(), + 'name' => 'Other', + 'email' => 'other@example.com', + ]); + $otherOrg = Organization::create([ + 'owner_ref' => $other->public_id, + 'name' => 'Other Org', + 'slug' => 'other-org', + 'settings' => ['onboarded' => true, 'plan' => 'free'], + ]); + + $this->artisan('demo:seed', ['identity' => $user->email, '--plan' => 'enterprise'])->assertSuccessful(); + $org = Organization::query()->where('owner_ref', $user->public_id)->firstOrFail(); + $firstVisits = Visit::query()->where('organization_id', $org->id)->count(); + + $this->artisan('demo:seed', ['identity' => $user->email, '--plan' => 'enterprise'])->assertSuccessful(); + $this->assertSame($firstVisits, Visit::query()->where('organization_id', $org->id)->count()); + + $this->artisan('demo:seed', [ + 'identity' => $user->email, + '--plan' => 'free', + '--reset' => true, + ])->assertSuccessful(); + + $this->assertNotNull(Organization::query()->find($otherOrg->id)); + $fresh = Organization::query()->where('owner_ref', $user->public_id)->firstOrFail(); + $this->assertSame('free', app(PlanService::class)->planKey($fresh)); + $this->assertSame(1, Branch::query()->where('organization_id', $fresh->id)->count()); + $this->assertSame(40, Visit::query()->where('organization_id', $fresh->id)->count()); + } +}