# 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 <key>`. Нет заголовка / не 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 = <tenantId>` (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: <name|null>} ... ], "next_cursor": <base64|null> }`.
  `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` | новый |
