|
| 1 | +<?php |
| 2 | + |
| 3 | +declare(strict_types=1); |
| 4 | + |
| 5 | +namespace App\Console\Commands; |
| 6 | + |
| 7 | +use App\Enums\Role; |
| 8 | +use App\Models\Member; |
| 9 | +use App\Models\Organization; |
| 10 | +use App\Models\User; |
| 11 | +use App\Service\ColorService; |
| 12 | +use Illuminate\Console\Command; |
| 13 | +use Illuminate\Support\Carbon; |
| 14 | +use Illuminate\Support\Facades\DB; |
| 15 | +use Illuminate\Support\Str; |
| 16 | + |
| 17 | +/** |
| 18 | + * Reproduction seeder for GitHub issue #1138 ("Performance with several clients or projects"). |
| 19 | + * |
| 20 | + * The web app loads the full clients/projects/tasks lists into the browser via |
| 21 | + * fetchAllPages() (see resources/js/utils/useProjectsQuery.ts etc.), paging through the |
| 22 | + * API 15 records at a time. With hundreds of clients and thousands of projects this means |
| 23 | + * a long burst of sequential requests and a huge client-side dataset that the Manage > Projects, |
| 24 | + * Manage > Clients and the time tracker project/client/task pickers then render and filter |
| 25 | + * in-browser — which is what degrades. |
| 26 | + * |
| 27 | + * This command builds a logged-in-able owner with that data volume so the behaviour can be |
| 28 | + * observed locally. Rows are inserted with the query builder (bypassing model events and |
| 29 | + * auditing) so the large volume seeds in seconds without polluting the audits table. |
| 30 | + */ |
| 31 | +class SeedPerformanceProjects extends Command |
| 32 | +{ |
| 33 | + protected $signature = 'seed:perf-projects |
| 34 | + {--clients=500 : Number of clients to create} |
| 35 | + {--projects=2500 : Number of projects to create} |
| 36 | + {--tasks-per-project=2 : Number of tasks to create per project (0 to skip)} |
| 37 | + {--email=perf-projects@test.test : Email for the test user} |
| 38 | + {--password=password : Password for the test user}'; |
| 39 | + |
| 40 | + protected $description = 'Create an owner with many clients/projects/tasks to reproduce the client/project list performance issue (GH #1138)'; |
| 41 | + |
| 42 | + public function handle(ColorService $colorService): int |
| 43 | + { |
| 44 | + $clientCount = max(0, (int) $this->option('clients')); |
| 45 | + $projectCount = max(0, (int) $this->option('projects')); |
| 46 | + $tasksPerProject = max(0, (int) $this->option('tasks-per-project')); |
| 47 | + $email = (string) $this->option('email'); |
| 48 | + $password = (string) $this->option('password'); |
| 49 | + |
| 50 | + if (User::where('email', $email)->exists()) { |
| 51 | + $this->error("User with email {$email} already exists. Delete it first or pass a different --email."); |
| 52 | + |
| 53 | + return self::FAILURE; |
| 54 | + } |
| 55 | + |
| 56 | + if ($projectCount > 0 && $clientCount === 0) { |
| 57 | + $this->warn('No clients requested — all projects will be created without a client.'); |
| 58 | + } |
| 59 | + |
| 60 | + $this->info("Creating performance test user: {$email}"); |
| 61 | + |
| 62 | + // Owner sees every project in the organization (projects:view:all), so all seeded |
| 63 | + // projects load regardless of visibility — no ProjectMember rows needed. |
| 64 | + $user = User::factory()->withPersonalOrganization()->create([ |
| 65 | + 'name' => 'Projects Perf Tester', |
| 66 | + 'email' => $email, |
| 67 | + 'password' => bcrypt($password), |
| 68 | + ]); |
| 69 | + |
| 70 | + $organization = Organization::factory()->withOwner($user)->create([ |
| 71 | + 'name' => 'Perf Projects Org', |
| 72 | + 'personal_team' => false, |
| 73 | + 'currency' => 'EUR', |
| 74 | + ]); |
| 75 | + |
| 76 | + Member::factory() |
| 77 | + ->forUser($user) |
| 78 | + ->forOrganization($organization) |
| 79 | + ->role(Role::Owner) |
| 80 | + ->create(); |
| 81 | + |
| 82 | + $user->current_team_id = $organization->id; |
| 83 | + $user->save(); |
| 84 | + |
| 85 | + $organizationId = $organization->getKey(); |
| 86 | + $faker = fake(); |
| 87 | + $now = Carbon::now(); |
| 88 | + |
| 89 | + // --- Clients ------------------------------------------------------------------- |
| 90 | + $clientIds = []; |
| 91 | + if ($clientCount > 0) { |
| 92 | + $this->info("Creating {$clientCount} clients..."); |
| 93 | + $bar = $this->output->createProgressBar($clientCount); |
| 94 | + $rows = []; |
| 95 | + for ($i = 0; $i < $clientCount; $i++) { |
| 96 | + $id = (string) Str::uuid(); |
| 97 | + $clientIds[] = $id; |
| 98 | + // Distinct, descending created_at keeps list ordering stable across pages. |
| 99 | + $createdAt = $now->copy()->subSeconds($clientCount - $i)->toDateTimeString(); |
| 100 | + $rows[] = [ |
| 101 | + 'id' => $id, |
| 102 | + 'name' => sprintf('Client %04d - %s', $i + 1, $faker->company()), |
| 103 | + 'organization_id' => $organizationId, |
| 104 | + 'created_at' => $createdAt, |
| 105 | + 'updated_at' => $createdAt, |
| 106 | + ]; |
| 107 | + $bar->advance(); |
| 108 | + } |
| 109 | + foreach (array_chunk($rows, 1000) as $chunk) { |
| 110 | + DB::table('clients')->insert($chunk); |
| 111 | + } |
| 112 | + $bar->finish(); |
| 113 | + $this->newLine(); |
| 114 | + } |
| 115 | + |
| 116 | + // --- Projects ------------------------------------------------------------------ |
| 117 | + $projectIds = []; |
| 118 | + if ($projectCount > 0) { |
| 119 | + $this->info("Creating {$projectCount} projects..."); |
| 120 | + $bar = $this->output->createProgressBar($projectCount); |
| 121 | + $suffixes = ['Website', 'Mobile App', 'Platform', 'Migration', 'Redesign', 'Integration', 'Rollout', 'Dashboard', 'API', 'Audit']; |
| 122 | + $rows = []; |
| 123 | + for ($i = 0; $i < $projectCount; $i++) { |
| 124 | + $id = (string) Str::uuid(); |
| 125 | + $projectIds[] = $id; |
| 126 | + // ~10% of projects have no client; the rest are spread round-robin over the clients. |
| 127 | + $client = ($clientCount === 0 || $i % 10 === 0) |
| 128 | + ? null |
| 129 | + : $clientIds[$i % $clientCount]; |
| 130 | + $billable = $i % 2 === 0; |
| 131 | + $createdAt = $now->copy()->subSeconds($projectCount - $i)->toDateTimeString(); |
| 132 | + $rows[] = [ |
| 133 | + 'id' => $id, |
| 134 | + 'name' => sprintf('Project %04d - %s %s', $i + 1, $faker->company(), $faker->randomElement($suffixes)), |
| 135 | + 'color' => $colorService->getRandomColor(), |
| 136 | + 'billable_rate' => $billable ? $faker->numberBetween(50, 1000) * 100 : null, |
| 137 | + 'is_public' => true, |
| 138 | + 'is_billable' => $billable, |
| 139 | + 'client_id' => $client, |
| 140 | + 'organization_id' => $organizationId, |
| 141 | + 'created_at' => $createdAt, |
| 142 | + 'updated_at' => $createdAt, |
| 143 | + ]; |
| 144 | + $bar->advance(); |
| 145 | + } |
| 146 | + foreach (array_chunk($rows, 1000) as $chunk) { |
| 147 | + DB::table('projects')->insert($chunk); |
| 148 | + } |
| 149 | + $bar->finish(); |
| 150 | + $this->newLine(); |
| 151 | + } |
| 152 | + |
| 153 | + // --- Tasks --------------------------------------------------------------------- |
| 154 | + $taskCount = 0; |
| 155 | + if ($tasksPerProject > 0 && $projectCount > 0) { |
| 156 | + $taskCount = $projectCount * $tasksPerProject; |
| 157 | + $this->info("Creating {$taskCount} tasks ({$tasksPerProject} per project)..."); |
| 158 | + $bar = $this->output->createProgressBar($taskCount); |
| 159 | + $taskNames = ['Design', 'Development', 'Testing', 'Code Review', 'Deployment', 'Planning', 'Research', 'Documentation', 'Bugfix', 'Meeting']; |
| 160 | + $rows = []; |
| 161 | + $createdAt = $now->toDateTimeString(); |
| 162 | + foreach ($projectIds as $projectId) { |
| 163 | + for ($t = 0; $t < $tasksPerProject; $t++) { |
| 164 | + $rows[] = [ |
| 165 | + 'id' => (string) Str::uuid(), |
| 166 | + 'name' => $taskNames[$t % count($taskNames)], |
| 167 | + 'project_id' => $projectId, |
| 168 | + 'organization_id' => $organizationId, |
| 169 | + 'created_at' => $createdAt, |
| 170 | + 'updated_at' => $createdAt, |
| 171 | + ]; |
| 172 | + $bar->advance(); |
| 173 | + if (count($rows) >= 1000) { |
| 174 | + DB::table('tasks')->insert($rows); |
| 175 | + $rows = []; |
| 176 | + } |
| 177 | + } |
| 178 | + } |
| 179 | + if ($rows !== []) { |
| 180 | + DB::table('tasks')->insert($rows); |
| 181 | + } |
| 182 | + $bar->finish(); |
| 183 | + $this->newLine(); |
| 184 | + } |
| 185 | + |
| 186 | + $this->newLine(); |
| 187 | + $this->info('Done! Log in to reproduce GH #1138:'); |
| 188 | + $this->info(" Email: {$email}"); |
| 189 | + $this->info(" Password: {$password}"); |
| 190 | + $this->info(" Clients: {$clientCount}"); |
| 191 | + $this->info(" Projects: {$projectCount}"); |
| 192 | + $this->info(" Tasks: {$taskCount}"); |
| 193 | + $this->newLine(); |
| 194 | + $this->line('Then open Manage > Projects, Manage > Clients, or the time tracker project/client/task pickers.'); |
| 195 | + |
| 196 | + return self::SUCCESS; |
| 197 | + } |
| 198 | +} |
0 commit comments