From a3f19464bd7379506b51b03292c27334485d0eb8 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Fri, 17 Jul 2026 10:04:40 +0000 Subject: [PATCH] Add Meet demo:seed for rooms and sessions. Seed org structure, rooms, and participant history with plan-based volume only. Co-authored-by: Cursor --- app/Console/Commands/DemoSeedCommand.php | 301 +++++++++++++++++++++++ tests/Feature/DemoSeedCommandTest.php | 121 +++++++++ 2 files changed, 422 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..022629e --- /dev/null +++ b/app/Console/Commands/DemoSeedCommand.php @@ -0,0 +1,301 @@ +option('plan')); + if (! in_array($plan, ['free', 'pro', 'enterprise'], true)) { + $this->error('Invalid --plan. Use free, pro, or enterprise.'); + + return self::FAILURE; + } + + $user = $this->resolveUser((string) $this->argument('identity')); + if (! $user) { + $this->error('User not found for identity: '.$this->argument('identity')); + + return self::FAILURE; + } + + $ownerRef = $user->ownerRef(); + + if ($this->option('reset')) { + $this->resetTenant($ownerRef); + $this->info('Reset demo Meet data for '.$user->email); + } + + $existing = Organization::query() + ->where('owner_ref', $ownerRef) + ->where('settings->demo', true) + ->exists(); + + if ($existing && ! $this->option('reset')) { + $this->info('Demo Meet workspace already present (idempotent). Plan only varies volume on fresh seed.'); + $this->line(sprintf('user=%s plan=%s owner_ref=%s', $user->email, $plan, $ownerRef)); + + return self::SUCCESS; + } + + $volume = $this->volumeFor($plan); + $org = $this->ensureOrganization($user, $plan); + $branches = $this->ensureBranches($org, $ownerRef, $volume['branches']); + $this->ensureOwnerMember($org, $ownerRef, $branches->first()?->id); + $this->seedRoomsAndSessions($org, $ownerRef, $branches, $volume); + + $this->info(sprintf( + 'Seeded demo Meet workspace for %s (plan=%s volume: %d branches, %d rooms).', + $user->email, + $plan, + $volume['branches'], + $volume['rooms'], + )); + + return self::SUCCESS; + } + + private function resolveUser(string $identity): ?User + { + return User::query() + ->where(function ($query) use ($identity) { + $query->where('email', $identity)->orWhere('public_id', $identity); + }) + ->first(); + } + + /** + * @return array{branches: int, rooms: int, past_sessions: int, upcoming: int, participants: int} + */ + private function volumeFor(string $plan): array + { + return match ($plan) { + 'enterprise' => [ + 'branches' => 3, + 'rooms' => 6, + 'past_sessions' => 4, + 'upcoming' => 3, + 'participants' => 5, + ], + 'pro' => [ + 'branches' => 2, + 'rooms' => 4, + 'past_sessions' => 3, + 'upcoming' => 2, + 'participants' => 4, + ], + default => [ + 'branches' => 1, + 'rooms' => 2, + 'past_sessions' => 1, + 'upcoming' => 1, + 'participants' => 3, + ], + }; + } + + private function resetTenant(string $ownerRef): void + { + DB::transaction(function () use ($ownerRef) { + $sessionIds = Session::query()->where('owner_ref', $ownerRef)->pluck('id'); + + if ($sessionIds->isNotEmpty()) { + Participant::query() + ->whereIn('session_id', $sessionIds) + ->update(['breakout_room_id' => null]); + + Participant::query()->whereIn('session_id', $sessionIds)->delete(); + Session::query()->whereIn('id', $sessionIds)->update(['parent_session_id' => null]); + Session::query()->whereIn('id', $sessionIds)->delete(); + } + + Room::withTrashed()->where('owner_ref', $ownerRef)->forceDelete(); + Member::query()->where('owner_ref', $ownerRef)->delete(); + Branch::withTrashed()->where('owner_ref', $ownerRef)->forceDelete(); + Organization::withTrashed()->where('owner_ref', $ownerRef)->forceDelete(); + + if (DB::getSchemaBuilder()->hasTable('meet_audit_logs')) { + DB::table('meet_audit_logs')->where('owner_ref', $ownerRef)->delete(); + } + }); + } + + private function ensureOrganization(User $user, string $plan): Organization + { + $slug = 'demo-'.Str::slug(Str::before($user->email, '@') ?: $user->public_id); + + return Organization::query()->updateOrCreate( + [ + 'owner_ref' => $user->ownerRef(), + 'slug' => $slug, + ], + [ + 'name' => $user->name.' Demo Org', + 'timezone' => 'Africa/Accra', + 'license_tier' => 'standard', + 'settings' => [ + 'onboarded' => true, + 'demo' => true, + 'demo_plan_hint' => $plan, + ], + ], + ); + } + + /** + * @return \Illuminate\Support\Collection + */ + private function ensureBranches(Organization $org, string $ownerRef, int $count) + { + $branches = collect(); + $names = ['Head Office', 'East Branch', 'West Branch']; + + for ($i = 0; $i < $count; $i++) { + $branches->push(Branch::query()->updateOrCreate( + [ + 'organization_id' => $org->id, + 'code' => 'DEMO-'.($i + 1), + ], + [ + 'owner_ref' => $ownerRef, + 'name' => $names[$i] ?? ('Branch '.($i + 1)), + 'is_active' => true, + ], + )); + } + + return $branches; + } + + private function ensureOwnerMember(Organization $org, string $ownerRef, ?int $branchId): void + { + Member::query()->updateOrCreate( + [ + 'organization_id' => $org->id, + 'user_ref' => $ownerRef, + ], + [ + 'owner_ref' => $ownerRef, + 'role' => 'owner', + 'branch_id' => $branchId, + ], + ); + } + + /** + * @param \Illuminate\Support\Collection $branches + * @param array{branches: int, rooms: int, past_sessions: int, upcoming: int, participants: int} $volume + */ + private function seedRoomsAndSessions(Organization $org, string $ownerRef, $branches, array $volume): void + { + $settings = config('meet.default_settings', []); + + for ($i = 0; $i < $volume['rooms']; $i++) { + $branch = $branches[$i % max($branches->count(), 1)] ?? null; + $isPast = $i < $volume['past_sessions']; + $isUpcoming = ! $isPast && ($i - $volume['past_sessions']) < $volume['upcoming']; + + $room = Room::query()->create([ + 'owner_ref' => $ownerRef, + 'organization_id' => $org->id, + 'branch_id' => $branch?->id, + 'host_user_ref' => $ownerRef, + 'title' => $isPast + ? 'Demo Past Meeting '.($i + 1) + : ($isUpcoming ? 'Demo Upcoming Meeting '.($i + 1) : 'Demo Standing Room '.($i + 1)), + 'type' => 'scheduled', + 'status' => $isPast ? 'ended' : 'scheduled', + 'scheduled_at' => $isPast + ? now()->subDays($i + 1)->setTime(10, 0) + : now()->addDays($i + 1)->setTime(15, 0), + 'duration_minutes' => 60, + 'timezone' => 'Africa/Accra', + 'settings' => $settings, + 'source' => [ + 'app' => 'demo', + 'entity_type' => 'seed', + 'entity_id' => 'room-'.($i + 1), + ], + ]); + + if ($isPast) { + $session = Session::query()->create([ + 'owner_ref' => $ownerRef, + 'room_id' => $room->id, + 'media_room_name' => 'meet-'.$room->uuid, + 'status' => 'ended', + 'started_at' => $room->scheduled_at, + 'ended_at' => $room->scheduled_at?->copy()->addHour(), + 'peak_participants' => $volume['participants'], + ]); + $this->seedParticipants($session, $ownerRef, $volume['participants'], left: true); + } elseif ($isUpcoming) { + // Upcoming rooms have no session yet (host has not started). + continue; + } else { + $session = Session::query()->create([ + 'owner_ref' => $ownerRef, + 'room_id' => $room->id, + 'media_room_name' => 'meet-'.$room->uuid, + 'status' => 'ended', + 'started_at' => now()->subHours(3), + 'ended_at' => now()->subHours(2), + 'peak_participants' => max(2, $volume['participants'] - 1), + ]); + $this->seedParticipants($session, $ownerRef, max(2, $volume['participants'] - 1), left: true); + } + } + } + + private function seedParticipants(Session $session, string $ownerRef, int $count, bool $left): void + { + Participant::query()->create([ + 'owner_ref' => $ownerRef, + 'session_id' => $session->id, + 'user_ref' => $ownerRef, + 'display_name' => 'Demo Host', + 'email' => null, + 'role' => 'host', + 'status' => $left ? 'left' : 'joined', + 'joined_at' => $session->started_at, + 'left_at' => $left ? $session->ended_at : null, + ]); + + for ($i = 1; $i < $count; $i++) { + Participant::query()->create([ + 'owner_ref' => $ownerRef, + 'session_id' => $session->id, + 'user_ref' => null, + 'display_name' => 'Demo Guest '.$i, + 'email' => 'guest'.$i.'@example.com', + 'role' => 'participant', + 'status' => $left ? 'left' : 'joined', + 'joined_at' => $session->started_at?->copy()->addMinutes($i), + 'left_at' => $left ? $session->ended_at : null, + ]); + } + } +} diff --git a/tests/Feature/DemoSeedCommandTest.php b/tests/Feature/DemoSeedCommandTest.php new file mode 100644 index 0000000..78dbca1 --- /dev/null +++ b/tests/Feature/DemoSeedCommandTest.php @@ -0,0 +1,121 @@ + 'meet-demo-free-001', + 'name' => 'Meet 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)->first(); + $this->assertNotNull($org); + $this->assertTrue((bool) data_get($org->settings, 'demo')); + $this->assertTrue((bool) data_get($org->settings, 'onboarded')); + + $this->assertDatabaseHas('meet_members', [ + 'organization_id' => $org->id, + 'user_ref' => $user->public_id, + 'role' => 'owner', + ]); + $this->assertSame(1, Branch::query()->where('organization_id', $org->id)->count()); + $this->assertSame(2, Room::query()->where('owner_ref', $user->public_id)->count()); + $this->assertGreaterThan(0, Session::query()->where('owner_ref', $user->public_id)->count()); + $this->assertGreaterThan(0, Participant::query()->where('owner_ref', $user->public_id)->count()); + $this->assertTrue( + Room::query()->where('owner_ref', $user->public_id)->where('status', 'scheduled')->exists() + ); + $this->assertTrue( + Room::query()->where('owner_ref', $user->public_id)->where('status', 'ended')->exists() + ); + } + + public function test_plan_only_varies_volume_and_makes_no_http_calls(): void + { + $user = User::create([ + 'public_id' => 'meet-demo-ent-001', + 'name' => 'Meet Demo Ent', + 'email' => 'demo-enterprise@ladill.com', + ]); + + Http::fake(); + + $this->artisan('demo:seed', ['identity' => $user->public_id, '--plan' => 'enterprise']) + ->assertSuccessful(); + + Http::assertNothingSent(); + + $this->assertSame(3, Branch::query()->where('owner_ref', $user->public_id)->count()); + $this->assertSame(6, Room::query()->where('owner_ref', $user->public_id)->count()); + } + + public function test_idempotent_and_reset_is_tenant_scoped(): void + { + $user = User::create([ + 'public_id' => 'meet-demo-pro-001', + 'name' => 'Meet Demo Pro', + 'email' => 'demo-pro@ladill.com', + ]); + $other = User::create([ + 'public_id' => 'meet-other-001', + 'name' => 'Other User', + 'email' => 'other@example.com', + ]); + + Organization::create([ + 'owner_ref' => $other->public_id, + 'name' => 'Other Org', + 'slug' => 'other-org', + 'timezone' => 'UTC', + 'settings' => ['onboarded' => true], + ]); + + $this->artisan('demo:seed', ['identity' => $user->email, '--plan' => 'pro']) + ->assertSuccessful(); + $rooms = Room::query()->where('owner_ref', $user->public_id)->count(); + $this->assertSame(4, $rooms); + + $this->artisan('demo:seed', ['identity' => $user->email, '--plan' => 'pro']) + ->assertSuccessful(); + $this->assertSame(4, Room::query()->where('owner_ref', $user->public_id)->count()); + + $this->artisan('demo:seed', [ + 'identity' => $user->email, + '--plan' => 'free', + '--reset' => true, + ])->assertSuccessful(); + + $this->assertSame(2, Room::query()->where('owner_ref', $user->public_id)->count()); + $this->assertSame(1, Member::query()->where('owner_ref', $user->public_id)->count()); + $this->assertDatabaseHas('meet_organizations', [ + 'owner_ref' => $other->public_id, + 'slug' => 'other-org', + ]); + } +}