diff --git a/app/app/Http/Controllers/Api/ProjectController.php b/app/app/Http/Controllers/Api/ProjectController.php index defa0af3..39cfb691 100644 --- a/app/app/Http/Controllers/Api/ProjectController.php +++ b/app/app/Http/Controllers/Api/ProjectController.php @@ -5,8 +5,10 @@ declare(strict_types=1); namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Http\Requests\StoreProjectRequest; use App\Http\Resources\ProjectResource; use App\Models\Project; +use App\Services\Project\ProjectService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -21,6 +23,8 @@ use Illuminate\Http\Request; */ class ProjectController extends Controller { + public function __construct(private readonly ProjectService $projects) {} + /** GET /api/projects */ public function index(Request $request): JsonResponse { @@ -78,6 +82,14 @@ class ProjectController extends Controller ]); } + /** POST /api/projects */ + public function store(StoreProjectRequest $request): JsonResponse + { + $project = $this->projects->create($request->user()->tenant, $request->validated()); + + return response()->json(['data' => new ProjectResource($project)], 201); + } + /** GET /api/projects/{id} */ public function show(Request $request, int $id): JsonResponse { diff --git a/app/app/Http/Requests/StoreProjectRequest.php b/app/app/Http/Requests/StoreProjectRequest.php new file mode 100644 index 00000000..0ef002b2 --- /dev/null +++ b/app/app/Http/Requests/StoreProjectRequest.php @@ -0,0 +1,42 @@ +user() !== null; + } + + public function rules(): array + { + $signalType = $this->input('signal_type'); + + $base = [ + 'name' => ['required', 'string', 'max:255'], + 'signal_type' => ['required', Rule::in(['site', 'call', 'sms'])], + 'daily_limit_target' => ['required', 'integer', 'min:1', 'max:10000'], + 'region_mask' => ['required', 'integer', 'min:0'], + 'region_mode' => ['required', Rule::in(['include', 'exclude'])], + 'delivery_days_mask' => ['required', 'integer', 'min:1', 'max:127'], + ]; + + if ($signalType === 'site') { + $base['signal_identifier'] = ['required', 'string', 'regex:/^[a-z0-9.\-]+\.[a-z]{2,}$/i']; + } elseif ($signalType === 'call') { + $base['signal_identifier'] = ['required', 'string', 'regex:/^7\d{10}$/']; + } elseif ($signalType === 'sms') { + $base['sms_senders'] = ['required', 'array', 'min:1']; + $base['sms_senders.*'] = ['string', 'max:11']; + $base['sms_keyword'] = ['nullable', 'string', 'max:50']; + } + + return $base; + } +} diff --git a/app/app/Jobs/SyncSupplierProjectJob.php b/app/app/Jobs/SyncSupplierProjectJob.php new file mode 100644 index 00000000..138bb500 --- /dev/null +++ b/app/app/Jobs/SyncSupplierProjectJob.php @@ -0,0 +1,23 @@ + 'integer', 'delivered_in_month' => 'integer', 'api_key_limit' => 'integer', + // JSONB: {"max_users":5,"max_projects":10,"api_rps":60} + 'limits' => 'array', 'webhook_token_rotated_at' => 'datetime', 'last_activity_at' => 'datetime', 'last_webhook_at' => 'datetime', diff --git a/app/app/Services/Project/ProjectService.php b/app/app/Services/Project/ProjectService.php new file mode 100644 index 00000000..e869fd49 --- /dev/null +++ b/app/app/Services/Project/ProjectService.php @@ -0,0 +1,32 @@ +limits['max_projects'] ?? 10); + $current = Project::where('tenant_id', $tenant->id)->active()->count(); + if ($current >= $limit) { + throw new HttpResponseException(response()->json([ + 'message' => "Достигнут лимит проектов ({$limit}). Смените тариф.", + ], 403)); + } + + $data['tenant_id'] = $tenant->id; + $data['is_active'] = true; + $project = Project::create($data); + + SyncSupplierProjectJob::dispatch($project->id); + + return $project->fresh(); + } +} diff --git a/app/database/migrations/2026_05_11_150000_add_limits_to_tenants.php b/app/database/migrations/2026_05_11_150000_add_limits_to_tenants.php new file mode 100644 index 00000000..3c2ab745 --- /dev/null +++ b/app/database/migrations/2026_05_11_150000_add_limits_to_tenants.php @@ -0,0 +1,32 @@ +limits['max_projects'] ?? 10) = 10 из сервиса. + */ +return new class extends Migration +{ + public function up(): void + { + Schema::table('tenants', function (Blueprint $table) { + // limits JSONB: {"max_users":5,"max_projects":10,"api_rps":60} + // Аналог limits в tariff_plans — per-tenant override лимитов тарифа. + $table->jsonb('limits')->default('{}')->after('api_key_limit'); + }); + } + + public function down(): void + { + Schema::table('tenants', function (Blueprint $table) { + $table->dropColumn('limits'); + }); + } +}; diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index b258b924..99c28436 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -864,6 +864,12 @@ parameters: count: 9 path: tests/Feature/Plan5/Projects/ProjectsListShowTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 8 + path: tests/Feature/Plan5/Projects/ProjectsStoreTest.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 65982b9f..9972aa87 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -150,6 +150,7 @@ Route::get('/api/lead-statuses', 'App\Http\Controllers\Api\LeadStatusController@ // после этой замены получит 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::post('/', 'App\Http\Controllers\Api\ProjectController@store')->name('projects.store'); Route::get('/{id}', 'App\Http\Controllers\Api\ProjectController@show')->name('projects.show')->where('id', '[0-9]+'); }); diff --git a/app/tests/Feature/Plan5/Projects/ProjectsStoreTest.php b/app/tests/Feature/Plan5/Projects/ProjectsStoreTest.php new file mode 100644 index 00000000..1f16e217 --- /dev/null +++ b/app/tests/Feature/Plan5/Projects/ProjectsStoreTest.php @@ -0,0 +1,131 @@ + Queue::fake()); + +it('creates a site project with valid payload', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + + $response = $this->actingAs($user)->postJson('/api/projects', [ + 'name' => 'Окна СПб', + 'signal_type' => 'site', + 'signal_identifier' => 'okna-spb.ru', + 'daily_limit_target' => 50, + 'region_mask' => 0, + 'region_mode' => 'include', + 'delivery_days_mask' => 127, + ]); + + $response->assertCreated(); + expect(Project::where('signal_identifier', 'okna-spb.ru')->exists())->toBeTrue(); + Queue::assertPushed(SyncSupplierProjectJob::class); +}); + +it('rejects invalid site domain', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + + $response = $this->actingAs($user)->postJson('/api/projects', [ + 'name' => 'X', 'signal_type' => 'site', 'signal_identifier' => 'not a domain', + 'daily_limit_target' => 50, 'region_mask' => 0, 'region_mode' => 'include', + 'delivery_days_mask' => 127, + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['signal_identifier']); +}); + +it('creates a call project with valid 11-digit phone', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + + $response = $this->actingAs($user)->postJson('/api/projects', [ + 'name' => 'Натяжные', 'signal_type' => 'call', 'signal_identifier' => '79161234567', + 'daily_limit_target' => 30, 'region_mask' => 0, 'region_mode' => 'include', + 'delivery_days_mask' => 127, + ]); + + $response->assertCreated(); +}); + +it('rejects call signal_identifier not starting with 7', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + + $response = $this->actingAs($user)->postJson('/api/projects', [ + 'name' => 'X', 'signal_type' => 'call', 'signal_identifier' => '89991234567', + 'daily_limit_target' => 30, 'region_mask' => 0, 'region_mode' => 'include', + 'delivery_days_mask' => 127, + ]); + + $response->assertStatus(422); +}); + +it('creates sms project with senders + keyword', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + + $response = $this->actingAs($user)->postJson('/api/projects', [ + 'name' => 'Ипотека', 'signal_type' => 'sms', + 'sms_senders' => ['TINKOFF'], 'sms_keyword' => 'ипотека', + 'daily_limit_target' => 100, 'region_mask' => 0, 'region_mode' => 'include', + 'delivery_days_mask' => 127, + ]); + + $response->assertCreated(); + $project = Project::where('name', 'Ипотека')->first(); + expect($project->sms_senders)->toBe(['TINKOFF']); + expect($project->sms_keyword)->toBe('ипотека'); +}); + +it('rejects sms project without sms_senders', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + + $response = $this->actingAs($user)->postJson('/api/projects', [ + 'name' => 'X', 'signal_type' => 'sms', + 'daily_limit_target' => 100, 'region_mask' => 0, 'region_mode' => 'include', + 'delivery_days_mask' => 127, + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['sms_senders']); +}); + +it('rejects when tenant exceeds max_projects limit', function () { + $tenant = Tenant::factory()->create(['limits' => ['max_projects' => 1]]); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + Project::factory()->create(['tenant_id' => $tenant->id]); + + $response = $this->actingAs($user)->postJson('/api/projects', [ + 'name' => 'second', 'signal_type' => 'site', 'signal_identifier' => 'second.ru', + 'daily_limit_target' => 10, 'region_mask' => 0, 'region_mode' => 'include', + 'delivery_days_mask' => 127, + ]); + + $response->assertStatus(403); +}); + +it('forces tenant_id from auth user (not from payload)', function () { + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + $userA = User::factory()->create(['tenant_id' => $tenantA->id]); + + $this->actingAs($userA)->postJson('/api/projects', [ + 'tenant_id' => $tenantB->id, // попытка инъекции + 'name' => 'X', 'signal_type' => 'site', 'signal_identifier' => 'x.ru', + 'daily_limit_target' => 10, 'region_mask' => 0, 'region_mode' => 'include', + 'delivery_days_mask' => 127, + ]); + + $project = Project::where('signal_identifier', 'x.ru')->latest()->first(); + expect($project->tenant_id)->toBe($tenantA->id); +}); diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index fec40acb..04c15e84 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -2,7 +2,7 @@ **Назначение:** консолидированный журнал изменений `schema.sql`. Содержит девятнадцать записей в обратном хронологическом порядке (v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog. -**Файл схемы:** `schema.sql` (текущая версия — v8.19, консолидированная — разворачивает БД с нуля). +**Файл схемы:** `schema.sql` (текущая версия — v8.20, консолидированная — разворачивает БД с нуля). **История записей:** @@ -10,9 +10,8 @@ **Added:** -- `projects.archived_at TIMESTAMPTZ NULL` — для soft archive flow (отличие от `is_active=false` который = pause). - -**Migration:** `app/database/migrations/2026_05_11_140000_add_archived_at_to_projects.php` +- `projects.archived_at TIMESTAMPTZ NULL` — для soft archive flow (отличие от `is_active=false` который = pause). **Migration:** `app/database/migrations/2026_05_11_140000_add_archived_at_to_projects.php` +- `tenants.limits JSONB NOT NULL DEFAULT '{}'` — per-tenant override лимитов тарифа; используется `ProjectService::create()` для проверки `max_projects`. **Migration:** `app/database/migrations/2026_05_11_150000_add_limits_to_tenants.php` ## v8.19 (2026-05-11) — Plan 4 Billing + CSV Reconcile + Admin diff --git a/db/schema.sql b/db/schema.sql index b1943b9d..5c989a9c 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,6 +1,6 @@ -- ============================================================================= -- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра») --- Версия: v8.20 (11.05.2026 — Plan 5 frontend projects UI: projects.archived_at TIMESTAMPTZ NULL для soft archive flow) +-- Версия: v8.20 (11.05.2026 — Plan 5 frontend projects UI: projects.archived_at TIMESTAMPTZ NULL для soft archive flow; tenants.limits JSONB NOT NULL DEFAULT '{}' для per-tenant project/user лимитов) -- Метрики: 62 базовые таблицы + 12 партиций / 117 индексов / 39 RLS-политик / 5 функций / 13 триггеров -- Базовая версия: v8.19 (11.05.2026 — Plan 4 billing+csv+admin: tenants.delivered_in_month, lead_charges.charge_source + CHECK, supplier_leads.recovered_from_csv_at, supplier_csv_reconcile_log) -- Базовая версия: v8.18 (10.05.2026 — Plan 2/5 Task 1: supplier_leads SaaS-level + projects.delivered_today + 2 system_settings rows для supplier-webhook + IP allowlist defense-in-depth) @@ -659,6 +659,10 @@ CREATE TABLE tenants ( -- Хранится в зашифрованном виде через Crypt::encryptString. Вне спринта 9 -- остаётся NULL и фича не активна (UI скрывает Telegram-настройки). telegram_bot_token TEXT, + -- Plan 5 Task 3: per-tenant override лимитов тарифа. + -- Используется ProjectService::create() для проверки max_projects. + -- {"max_users":5,"max_projects":10,"api_rps":60} + limits JSONB NOT NULL DEFAULT '{}', -- Метаданные created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ,