diff --git a/app/app/Http/Controllers/Api/ProjectController.php b/app/app/Http/Controllers/Api/ProjectController.php index a3c88e5e..2575207c 100644 --- a/app/app/Http/Controllers/Api/ProjectController.php +++ b/app/app/Http/Controllers/Api/ProjectController.php @@ -5,48 +5,79 @@ declare(strict_types=1); namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Http\Resources\ProjectResource; use App\Models\Project; -use App\Models\Tenant; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\DB; /** - * Проекты tenant'а — для NewDealDialog dropdown'а и DealsView/Smart-filters. + * Проекты tenant'а — расширенный API для ProjectsView + NewDealDialog. * - * На MVP: tenant_id параметром. На prod: middleware('auth:sanctum')+'tenant'. + * index: фильтры по signal_type/status/search, пагинация, batch-fetch по ids. + * show: детальная карточка проекта с supplier_links. + * + * Auth: auth:sanctum + tenant middleware (устанавливает app.current_tenant_id для RLS). + * Task 2 Plan 5 заменяет MVP-версию (tenant_id параметром, без auth). */ class ProjectController extends Controller { - /** GET /api/projects?tenant_id={id} */ + /** GET /api/projects */ public function index(Request $request): JsonResponse { - $tenantId = (int) $request->query('tenant_id', '0'); - if ($tenantId < 1) { - return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422); + $query = Project::query()->where('tenant_id', $request->user()->tenant_id); + + // Batch-fetch по ids — возвращает без пагинации (для dropdown'ов и т.п.) + if ($ids = $request->query('ids')) { + $idArray = array_filter(array_map('intval', explode(',', (string) $ids))); + $items = $query->whereIn('id', $idArray)->get(); + + return response()->json(['data' => ProjectResource::collection($items)]); } - $tenant = Tenant::find($tenantId); - if ($tenant === null) { - return response()->json(['message' => 'Тенант не найден.'], 404); + // Фильтр по типу сигнала + if ($type = $request->query('signal_type')) { + $query->where('signal_type', $type); } - $projects = DB::transaction(function () use ($tenantId) { - DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); + // Фильтр по статусу жизненного цикла + $status = $request->query('status'); + if ($status === 'archived') { + $query->archived(); + } elseif ($status === 'active') { + $query->active()->where('is_active', true); + } elseif ($status === 'paused') { + $query->active()->where('is_active', false); + } else { + // По умолчанию: все не архивированные (active + paused) + $query->active(); + } - return Project::query() - ->where('is_active', true) - ->orderBy('name') - ->get(['id', 'name', 'tag', 'type']); - }); + // Поиск по name и signal_identifier + if ($search = $request->query('search')) { + $query->where(function ($q) use ($search) { + $q->where('name', 'ilike', "%{$search}%") + ->orWhere('signal_identifier', 'ilike', "%{$search}%"); + }); + } + + $perPage = min((int) $request->query('per_page', '20'), 100); + $projects = $query->orderBy('created_at', 'desc')->paginate($perPage); return response()->json([ - 'projects' => $projects->map(fn (Project $p) => [ - 'id' => $p->id, - 'name' => $p->name, - 'tag' => $p->tag, - 'type' => $p->type, - ]), + 'data' => ProjectResource::collection($projects->items()), + 'meta' => [ + 'current_page' => $projects->currentPage(), + 'per_page' => $projects->perPage(), + 'total' => $projects->total(), + ], ]); } + + /** GET /api/projects/{id} */ + public function show(Request $request, int $id): JsonResponse + { + $project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id); + + return response()->json(['data' => new ProjectResource($project)]); + } } diff --git a/app/app/Http/Resources/ProjectResource.php b/app/app/Http/Resources/ProjectResource.php new file mode 100644 index 00000000..88ab4db6 --- /dev/null +++ b/app/app/Http/Resources/ProjectResource.php @@ -0,0 +1,43 @@ +resource; + + return [ + 'id' => $this->id, + 'name' => $this->name, + 'signal_type' => $this->signal_type, + 'signal_identifier' => $this->signal_identifier, + 'sms_senders' => $this->sms_senders, + 'sms_keyword' => $this->sms_keyword, + 'daily_limit_target' => $this->daily_limit_target, + 'effective_daily_limit_today' => $this->effective_daily_limit_today, + 'delivered_today' => $this->delivered_today, + 'delivered_in_month' => $this->delivered_in_month, + 'is_active' => $this->is_active, + 'archived_at' => $project->archived_at?->toIso8601String(), + 'region_mask' => $this->region_mask, + 'region_mode' => $this->region_mode, + 'delivery_days_mask' => $this->delivery_days_mask, + 'sync_status' => $this->aggregateSyncStatus(), + 'last_synced_at' => $this->aggregateLastSyncedAt(), + 'supplier_links' => $this->when( + $request->routeIs('projects.show'), + fn () => $this->getSupplierLinks(), + ), + ]; + } +} diff --git a/app/app/Models/Project.php b/app/app/Models/Project.php index 89c3abe8..7f1e1a15 100644 --- a/app/app/Models/Project.php +++ b/app/app/Models/Project.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Collection; /** * Проект (лид-канал) внутри тенанта. @@ -157,4 +158,70 @@ class Project extends Model { return $query->whereNotNull('archived_at'); } + + /** + * Агрегированный статус синхронизации по всем связанным SupplierProject. + * + * Логика: если нет ни одного — pending; если есть failed — failed; + * если есть pending — pending; иначе — ok. + */ + public function aggregateSyncStatus(): string + { + /** @var Collection $statuses */ + $statuses = collect(['supplier_b1_project_id', 'supplier_b2_project_id', 'supplier_b3_project_id']) + ->map(fn (string $col) => $this->{$col} ? SupplierProject::find($this->{$col})?->sync_status : null) + ->filter(); + + if ($statuses->isEmpty()) { + return 'pending'; + } + if ($statuses->contains('failed')) { + return 'failed'; + } + if ($statuses->contains('pending')) { + return 'pending'; + } + + return 'ok'; + } + + /** + * Минимальная дата последней синхронизации по всем связанным SupplierProject. + */ + public function aggregateLastSyncedAt(): ?string + { + $ts = collect(['supplier_b1_project_id', 'supplier_b2_project_id', 'supplier_b3_project_id']) + ->map(fn (string $col) => $this->{$col} ? SupplierProject::find($this->{$col})?->last_synced_at : null) + ->filter() + ->min(); + + return $ts?->toIso8601String(); + } + + /** + * Массив ссылок на связанные SupplierProject (для show endpoint). + * + * @return array + */ + public function getSupplierLinks(): array + { + return collect(['b1', 'b2', 'b3']) + ->map(function (string $p) { + $id = $this->{"supplier_{$p}_project_id"}; + if ($id === null) { + return null; + } + $sp = SupplierProject::find($id); + + return [ + 'platform' => $p, + 'supplier_project_id' => $id, + 'sync_status' => $sp?->sync_status, + 'last_synced_at' => $sp?->last_synced_at?->toIso8601String(), + ]; + }) + ->filter() + ->values() + ->all(); + } } diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index e54809b9..42823c0b 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -78,6 +78,12 @@ parameters: count: 1 path: app/Http/Middleware/SetTenantContext.php + - + message: '#^Access to an undefined property App\\Models\\Project\:\:\$archived_at\.$#' + identifier: property.notFound + count: 1 + path: app/Http/Resources/ProjectResource.php + - message: '#^Using nullsafe property access "\?\-\>name" on left side of \?\? is unnecessary\. Use \-\> instead\.$#' identifier: nullsafe.neverNull @@ -852,6 +858,12 @@ parameters: count: 2 path: tests/Feature/PartitionsCreateMonthsTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 7 + path: tests/Feature/Plan5/Projects/ProjectsListShowTest.php + - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' identifier: property.notFound diff --git a/app/routes/web.php b/app/routes/web.php index e901720f..65982b9f 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -142,9 +142,17 @@ Route::post('/api/deals/restore', 'App\Http\Controllers\Api\DealBulkActionContro // Lookup endpoints — заполняют v-select'ы (NewDealDialog, smart-filters). Route::get('/api/managers', 'App\Http\Controllers\Api\ManagerController@index'); -Route::get('/api/projects', 'App\Http\Controllers\Api\ProjectController@index'); Route::get('/api/lead-statuses', 'App\Http\Controllers\Api\LeadStatusController@index'); +// Plan 5 Task 2: Projects CRUD — расширенный API с auth:sanctum + RLS. +// Заменяет старый GET /api/projects?tenant_id={id} (без auth, MVP-версия). +// ⚠️ NewDealDialog использовал старый endpoint (tenant_id param, без auth) — +// после этой замены получит 401. Defer fix до Task 7 (frontend phase). +Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/projects')->group(function () { + Route::get('/', 'App\Http\Controllers\Api\ProjectController@index')->name('projects.index'); + Route::get('/{id}', 'App\Http\Controllers\Api\ProjectController@show')->name('projects.show')->where('id', '[0-9]+'); +}); + // Receive endpoint для входящих webhook'ов (narrative §5.5). // Auth — по `tenants.webhook_token` в URL (без middleware, проверка внутри controller). // На prod: + HMAC-валидация X-Webhook-Signature + per-token rate-limit. diff --git a/app/tests/Feature/Plan5/Projects/ProjectsListShowTest.php b/app/tests/Feature/Plan5/Projects/ProjectsListShowTest.php new file mode 100644 index 00000000..9074ccec --- /dev/null +++ b/app/tests/Feature/Plan5/Projects/ProjectsListShowTest.php @@ -0,0 +1,89 @@ +create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + Project::factory()->count(3)->create(['tenant_id' => $tenant->id, 'signal_type' => 'site', 'signal_identifier' => 'example.com']); + + $response = $this->actingAs($user)->getJson('/api/projects'); + + $response->assertOk(); + $response->assertJsonStructure([ + 'data' => [['id', 'name', 'signal_type', 'signal_identifier', 'daily_limit_target', + 'delivered_today', 'is_active', 'archived_at', 'sync_status']], + 'meta' => ['current_page', 'per_page', 'total'], + ]); + expect($response->json('meta.total'))->toBe(3); +}); + +it('filters list by signal_type', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + Project::factory()->create(['tenant_id' => $tenant->id, 'signal_type' => 'site', 'signal_identifier' => 'example.com']); + Project::factory()->create(['tenant_id' => $tenant->id, 'signal_type' => 'call', 'signal_identifier' => '+79001234567']); + + $response = $this->actingAs($user)->getJson('/api/projects?signal_type=site'); + + expect($response->json('meta.total'))->toBe(1); +}); + +it('isolates projects per tenant (RLS)', function () { + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + $userA = User::factory()->create(['tenant_id' => $tenantA->id]); + Project::factory()->count(2)->create(['tenant_id' => $tenantA->id]); + Project::factory()->count(5)->create(['tenant_id' => $tenantB->id]); + + $response = $this->actingAs($userA)->getJson('/api/projects'); + + expect($response->json('meta.total'))->toBe(2); +}); + +it('excludes archived projects by default', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + Project::factory()->create(['tenant_id' => $tenant->id]); + Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => now()]); + + $response = $this->actingAs($user)->getJson('/api/projects'); + + expect($response->json('meta.total'))->toBe(1); +}); + +it('returns archived when status=archived requested', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + Project::factory()->create(['tenant_id' => $tenant->id, 'archived_at' => now()]); + + $response = $this->actingAs($user)->getJson('/api/projects?status=archived'); + + expect($response->json('meta.total'))->toBe(1); +}); + +it('returns batch fetch by ids without pagination', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $projects = Project::factory()->count(3)->create(['tenant_id' => $tenant->id]); + $ids = $projects->pluck('id')->take(2)->implode(','); + + $response = $this->actingAs($user)->getJson("/api/projects?ids={$ids}"); + + expect(count($response->json('data')))->toBe(2); +}); + +it('show returns project with supplier_links array', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $project = Project::factory()->create(['tenant_id' => $tenant->id]); + + $response = $this->actingAs($user)->getJson("/api/projects/{$project->id}"); + + $response->assertOk(); + $response->assertJsonStructure(['data' => ['id', 'name', 'supplier_links']]); +});