Files
ladill-frontdesk/app/Console/Commands/DemoSeedCommand.php
T
isaaccladandCursor 0bdf435328
Deploy Ladill Frontdesk / deploy (push) Successful in 2m6s
Add Frontdesk demo:seed for visitor walkthroughs.
Seed orgs, branches, kiosks, hosts, and visits with plan-aware caps and reset.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:03:15 +00:00

397 lines
16 KiB
PHP

<?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()],
],
);
}
}