diff --git a/app/app/Http/Controllers/Api/ProjectController.php b/app/app/Http/Controllers/Api/ProjectController.php index be2c10a1..c5698d8b 100644 --- a/app/app/Http/Controllers/Api/ProjectController.php +++ b/app/app/Http/Controllers/Api/ProjectController.php @@ -143,32 +143,13 @@ class ProjectController extends Controller public function bulk(BulkProjectActionRequest $request): JsonResponse { $tenantId = $request->user()->tenant_id; - $action = $request->validated('action'); + $ids = $this->projects->resolveBulkScope( + $tenantId, + $request->validated('ids'), + $request->validated('scope.filter'), + ); - // Resolve target IDs: explicit list takes priority; otherwise expand from scope.filter. - if ($request->has('scope.filter')) { - $filter = $request->validated('scope.filter') ?? []; - $query = Project::where('tenant_id', $tenantId); - if (! empty($filter['signal_type'])) { - $query->where('signal_type', $filter['signal_type']); - } - if (! empty($filter['status'])) { - match ($filter['status']) { - 'active' => $query->whereNull('archived_at')->where('is_active', true), - 'paused' => $query->whereNull('archived_at')->where('is_active', false), - 'archived' => $query->whereNotNull('archived_at'), - default => null, - }; - } - if (! empty($filter['search'])) { - $query->where('name', 'ilike', '%'.$filter['search'].'%'); - } - $ids = $query->limit(500)->pluck('id')->all(); - } else { - $ids = $request->validated('ids') ?? []; - } - - if (count($ids) > 500) { + if (count($ids) > ProjectService::BULK_MAX) { return response()->json([ 'errors' => ['scope' => ['Слишком много проектов под фильтр (>500). Уточните фильтры или выберите вручную.']], ], 422); @@ -176,7 +157,7 @@ class ProjectController extends Controller $payload = array_merge($request->validated(), ['ids' => $ids]); - $result = $this->projects->bulkAction($tenantId, $action, $payload); + $result = $this->projects->bulkAction($tenantId, $request->validated('action'), $payload); return response()->json($result); } diff --git a/app/app/Services/Project/ProjectService.php b/app/app/Services/Project/ProjectService.php index 22dc59ce..4903852f 100644 --- a/app/app/Services/Project/ProjectService.php +++ b/app/app/Services/Project/ProjectService.php @@ -60,6 +60,34 @@ class ProjectService SyncSupplierProjectJob::dispatch($project->id); } + public const BULK_MAX = 500; + + public function resolveBulkScope(int $tenantId, ?array $ids, ?array $filter): array + { + if (! empty($ids)) { + return array_values(array_unique($ids)); + } + + $query = Project::where('tenant_id', $tenantId); + + if (! empty($filter['signal_type'])) { + $query->where('signal_type', $filter['signal_type']); + } + if (! empty($filter['status'])) { + match ($filter['status']) { + 'active' => $query->where('is_active', true)->whereNull('archived_at'), + 'paused' => $query->where('is_active', false)->whereNull('archived_at'), + 'archived' => $query->whereNotNull('archived_at'), + default => null, + }; + } + if (! empty($filter['search'])) { + $query->where('name', 'ilike', '%'.$filter['search'].'%'); + } + + return $query->pluck('id')->all(); + } + public function bulkAction(int $tenantId, string $action, array $payload): array { $ids = $payload['ids'] ?? []; diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 7c11f8a1..09fa35e3 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -249,7 +249,7 @@ parameters: - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' identifier: method.notFound - count: 10 + count: 12 path: tests/Feature/Api/ProjectBulkActionsTest.php - diff --git a/app/tests/Feature/Api/ProjectBulkActionsTest.php b/app/tests/Feature/Api/ProjectBulkActionsTest.php index beed267c..2e742a76 100644 --- a/app/tests/Feature/Api/ProjectBulkActionsTest.php +++ b/app/tests/Feature/Api/ProjectBulkActionsTest.php @@ -172,3 +172,35 @@ it('applies update_limit replace with skip for conflicts', function () { expect($p1->fresh()->daily_limit_target)->toBe(100); expect($p2->fresh()->daily_limit_target)->toBe(200); }); + +it('resolves scope.filter to project ids and applies action', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->for($tenant)->create(); + Project::factory()->for($tenant)->asSiteSignal('example.com')->count(3)->create(['is_active' => true]); + Project::factory()->for($tenant)->asSmsSignal(['SENDER'])->count(2)->create(['is_active' => true]); + + $this->actingAs($user) + ->postJson('/api/projects/bulk', [ + 'action' => 'pause', + 'scope' => ['filter' => ['signal_type' => 'site']], + ]) + ->assertOk() + ->assertJson(['updated' => 3]); + + expect(Project::where('tenant_id', $tenant->id)->where('signal_type', 'site')->where('is_active', false)->count())->toBe(3); + expect(Project::where('tenant_id', $tenant->id)->where('signal_type', 'sms')->where('is_active', true)->count())->toBe(2); +}); + +it('rejects bulk when scope.filter captures more than 500 projects', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->for($tenant)->create(); + Project::factory()->for($tenant)->count(501)->create(); + + $this->actingAs($user) + ->postJson('/api/projects/bulk', [ + 'action' => 'pause', + 'scope' => ['filter' => []], + ]) + ->assertStatus(422) + ->assertJsonValidationErrors(['scope']); +});