Add Frontdesk demo:seed for visitor walkthroughs.
Deploy Ladill Frontdesk / deploy (push) Successful in 2m6s
Deploy Ladill Frontdesk / deploy (push) Successful in 2m6s
Seed orgs, branches, kiosks, hosts, and visits with plan-aware caps and reset. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Branch;
|
||||
use App\Models\Building;
|
||||
use App\Models\Device;
|
||||
use App\Models\Employee;
|
||||
use App\Models\EmployeePresence;
|
||||
use App\Models\EmployeePresenceEvent;
|
||||
use App\Models\Host;
|
||||
use App\Models\Member;
|
||||
use App\Models\MessagingCredential;
|
||||
use App\Models\NotificationUsage;
|
||||
use App\Models\OfflineCheckIn;
|
||||
use App\Models\Organization;
|
||||
use App\Models\ReceptionDesk;
|
||||
use App\Models\User;
|
||||
use App\Models\Visit;
|
||||
use App\Models\Visitor;
|
||||
use App\Models\WatchlistEntry;
|
||||
use App\Models\WebhookEndpoint;
|
||||
use App\Services\Frontdesk\PlanService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Seed plan-aware Frontdesk demo data for a suite demo identity.
|
||||
*/
|
||||
class DemoSeedCommand extends Command
|
||||
{
|
||||
protected $signature = 'demo:seed
|
||||
{identity : Email or public_id of the local mirrored user}
|
||||
{--plan=free : free|pro|enterprise}
|
||||
{--reset : Remove prior demo data for this owner_ref before seeding}
|
||||
{--public-id= : Platform public_id when creating a local mirror}
|
||||
{--email= : Email when creating a local mirror}
|
||||
{--name= : Display name when creating a local mirror}';
|
||||
|
||||
protected $description = 'Seed Frontdesk demo data for a suite demo account';
|
||||
|
||||
public function handle(PlanService $plans): int
|
||||
{
|
||||
$plan = strtolower((string) $this->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()],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Branch;
|
||||
use App\Models\Device;
|
||||
use App\Models\Host;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Models\Visit;
|
||||
use App\Models\Visitor;
|
||||
use App\Models\WatchlistEntry;
|
||||
use App\Services\Frontdesk\PlanService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DemoSeedCommandTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_free_plan_caps_branch_and_kiosk_with_visit_history(): void
|
||||
{
|
||||
$user = User::create([
|
||||
'public_id' => (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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user