From abee37524e301d576dfd96db43e2ac447c84256c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 19 Jun 2026 10:07:11 +0300 Subject: [PATCH] =?UTF-8?q?docs:=20G6=20=D0=B4=D0=B8=D0=B7=D0=B0=D0=B9?= =?UTF-8?q?=D0=BD=20=E2=80=94=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D1=87=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20read-API=20=D1=81=D0=B4=D0=B5=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=20=D0=BF=D0=BE=20API-=D0=BA=D0=BB=D1=8E=D1=87=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-19-g6-public-deals-api-design.md | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-19-g6-public-deals-api-design.md diff --git a/docs/superpowers/specs/2026-06-19-g6-public-deals-api-design.md b/docs/superpowers/specs/2026-06-19-g6-public-deals-api-design.md new file mode 100644 index 00000000..6acbefb5 --- /dev/null +++ b/docs/superpowers/specs/2026-06-19-g6-public-deals-api-design.md @@ -0,0 +1,154 @@ +# G6 — публичный read-API сделок по API-ключу — дизайн + +**Дата:** 19.06.2026 +**Статус:** дизайн утверждён владельцем (брейншторм) +**Находка:** G6 (go-live). API-ключи тенанта есть (`ApiKeyController`), но потребляющего endpoint'а нет — ключ декоративен. +**Граница:** backend. Без UI-правок, без write, без рейт-лимита (MVP). + +--- + +## 1. Цель + +Дать клиенту (тенанту) программный read-доступ к его **сделкам** (`deals`) по +выданному API-ключу — чтобы он мог выгружать свои сделки в свою систему/CRM. +Тогда API-ключ перестаёт быть декорацией. + +**Терминология (выверено по схеме, не путать):** +- **Лиды** = `supplier_leads` — сырой приход от поставщика (webhook payload, + платформа B1/B2/B3/DIRECT, vid). Внутреннее/поставщицкое, **без** `tenant_id`. + Наружу клиенту НЕ отдаём. +- **Сделки** = `deals` — клиентская сущность в Лидерре (`tenant_id`, телефон, + статус, имя, город, проект…). **Их и отдаёт публичный API.** + +--- + +## 2. Готовая инфраструктура (не меняем) + +- `api_keys` (схема): `tenant_id`, `key_hash` (bcrypt, UNIQUE), `key_prefix` + (VARCHAR(10)), `scopes` (JSONB, дефолт `["read"]`), `last_used_at`, + `last_used_ip` (INET), `expires_at` (NOT NULL, дефолт +365д), `is_active`. + RLS — tenant isolation; индекс `idx_api_keys_tenant ON api_keys(tenant_id) WHERE is_active`. +- Генерация (`ApiKeyController::regenerate`): ключ = `lpkapi_` + `Str::random(48)`; + в БД — `key_hash = Hash::make(plain)`, `key_prefix = substr(plain, 0, 10)`; + один активный ключ на тенанта; scope `["read"]`; показывается один раз. +- Сделки (`deals`, партиционирована по `received_at`): поля для контракта — + `id`, `received_at`, `phone`, `contact_name`, `city`, `status`, `project_id`. + Стоимости на сделке нет (цена — биллинг-списание; вне MVP). + +--- + +## 3. Архитектура + +Три новых юнита: + +### 3.1. Middleware `App\Http\Middleware\ApiKeyAuth` + +Аутентификация входящего запроса по ключу: + +1. Читает `Authorization: Bearer `. Нет заголовка / не Bearer / пусто → `401`. +2. `prefix = substr($key, 0, 10)`. +3. Кандидаты: `ApiKey::on('pgsql_supplier')->where('key_prefix', $prefix) + ->where('is_active', true)->where('expires_at', '>', now())->get()`. + **`pgsql_supplier` (BYPASSRLS)** — публичный роут не ставит tenant-GUC, под RLS + запрос к `api_keys` вернул бы пусто (паттерн RegistrationService). +4. По кандидатам: первый, у кого `Hash::check($key, $candidate->key_hash)` — матч. + Нет матча → `401`. +5. Scope: если `'read'` не в `$candidate->scopes` → `403`. +6. Успех: обновить `last_used_at = now()`, `last_used_ip = $request->ip()` (через + `pgsql_supplier`); положить `tenant_id` в `$request->attributes` (ключ + `api_tenant_id`). `next($request)`. + +Алиас `apikey` регистрируется в `bootstrap/app.php` (`$middleware->alias([...])`). + +Все ответы middleware — JSON `{ "message": "..." }` с нужным кодом. + +### 3.2. Контроллер `App\Http\Controllers\Api\V1\DealsController@index` + +`GET /api/v1/deals`: + +- `tenantId = (int) $request->attributes->get('api_tenant_id')`. +- Параметры: `limit` (1..500, дефолт 100), `since` (nullable date → `received_at >=`), + `cursor` (opaque base64 `{r:received_at, i:id}`, keyset). +- Внутри `DB::transaction`: `SET LOCAL app.current_tenant_id = ` (RLS) + + явный `->where('tenant_id', $tenantId)` (defense-in-depth, как в сессионном + DealController). Партиция по `received_at` уже существует. +- Запрос: `Deal::query()->where('tenant_id',$tenantId)->whereNull('deleted_at')` + (+`since` если задан, +keyset `(received_at,id) < (cursor)` если задан), + `orderByDesc('received_at')->orderByDesc('id')->limit($limit + 1)`; + `with('project:id,name')`. +- Ответ: `{ "data": [ {id, received_at, phone, contact_name, city, status, + project: } ... ], "next_cursor": }`. + `next_cursor` строится из последней строки при наличии (`limit+1` trick). +- Битый cursor (не base64 / нет полей r,i) → `422` JSON. + +### 3.3. Роут + +В `routes/web.php` — новая группа: + +```php +Route::middleware('apikey')->prefix('/api/v1')->group(function () { + Route::get('/deals', 'App\Http\Controllers\Api\V1\DealsController@index')->name('api.v1.deals.index'); +}); +``` + +Без `auth:sanctum`/`tenant` middleware (аутентификация и tenant-контекст — +внутри `ApiKeyAuth`/контроллера). + +--- + +## 4. Обработка ошибок + +| Ситуация | Код | Тело | +|---|---|---| +| Нет/не-Bearer/пустой ключ | 401 | `{"message":"Требуется API-ключ."}` | +| Ключ не найден/просрочен/неактивен | 401 | `{"message":"Неверный или неактивный API-ключ."}` | +| Нет scope `read` | 403 | `{"message":"Недостаточно прав ключа."}` | +| Битый cursor | 422 | `{"message":"Невалидный cursor."}` | + +--- + +## 5. Критерий приёмки (Pest) + +`app/tests/Feature/Api/V1/PublicDealsApiTest.php` +(`uses(DatabaseTransactions::class, SharesSupplierPdo::class)`): + +Хелпер: создать активный ключ с известным plain — +`$plain = 'lpkapi_'.Str::random(48); ApiKey::create([... key_hash=Hash::make($plain), +key_prefix=substr($plain,0,10), scopes=['read'], expires_at=now()->addYear(), +is_active=true ...])`. + +1. **Валидный ключ → 200 + свои сделки.** Тенант A с ключом и 2 сделками, + тенант B с 1 сделкой → `GET /api/v1/deals` с ключом A: 200, ровно 2 элемента, + сделка B не видна (изоляция). +2. **Нет заголовка → 401.** +3. **Неверный ключ → 401** (правильный префикс, но `Hash::check` не проходит). +4. **Просроченный ключ → 401** (`expires_at` в прошлом). +5. **Неактивный ключ → 401** (`is_active=false`). +6. **`last_used_at` обновляется** после успешного запроса (был null → не null). +7. **`since`-фильтр**: сделка старше `since` не попадает в ответ. + +Все — GREEN на `composer --working-dir=app test`. + +--- + +## 6. YAGNI / границы + +- Только сделки (без проектов/баланса/статистики). +- Только read (write/delete — нет). +- Без рейт-лимита (можно добавить позже отдельной находкой). +- Без `cost` на сделке (стоимость — биллинг-списание, отдельная привязка; F2). +- Без UI-правок и без публичной документации эндпоинта (мелкий follow-up, если + владелец захочет описание для клиентов). +- Scope-модель не расширяем — все ключи уже `["read"]`. + +--- + +## 7. Затрагиваемые файлы + +| Файл | Действие | +|---|---| +| `app/app/Http/Middleware/ApiKeyAuth.php` | новый | +| `app/app/Http/Controllers/Api/V1/DealsController.php` | новый | +| `app/bootstrap/app.php` | +алиас middleware `apikey` | +| `app/routes/web.php` | +группа `/api/v1` | +| `app/tests/Feature/Api/V1/PublicDealsApiTest.php` | новый |