# G6 — публичный read-API сделок по API-ключу — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Дать клиенту программный read-доступ к его сделкам (`deals`) по API-ключу через `GET /api/v1/deals`.

**Architecture:** Middleware `ApiKeyAuth` аутентифицирует по `Authorization: Bearer <key>` (поиск активного ключа по `key_prefix` через BYPASSRLS `pgsql_supplier` + `Hash::check`), кладёт `api_tenant_id` в request. Контроллер `Api\V1\DealsController` отдаёт сделки тенанта (RLS через `SET LOCAL app.current_tenant_id` + явный where), keyset-пагинация. Роут `/api/v1/deals` под алиасом `apikey`.

**Tech Stack:** Laravel 13, PostgreSQL 16 (RLS, partitioned deals), Pest 4. Backend-only.

**Spec:** [docs/superpowers/specs/2026-06-19-g6-public-deals-api-design.md](../specs/2026-06-19-g6-public-deals-api-design.md)

---

## File Structure

| Файл | Ответственность |
|---|---|
| `app/tests/Feature/Api/V1/PublicDealsApiTest.php` | **новый** — приёмка (auth + изоляция + last_used + since) |
| `app/app/Http/Middleware/ApiKeyAuth.php` | **новый** — аутентификация по API-ключу |
| `app/app/Http/Controllers/Api/V1/DealsController.php` | **новый** — отдача сделок тенанта |
| `app/bootstrap/app.php` | **правка** — +алиас `apikey` |
| `app/routes/web.php` | **правка** — +группа `/api/v1` |

---

## Task 1: Тест приёмки (RED)

**Files:**
- Create: `app/tests/Feature/Api/V1/PublicDealsApiTest.php`

- [ ] **Step 1: Написать тест**

```php
<?php

declare(strict_types=1);

use App\Models\ApiKey;
use App\Models\Deal;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Tests\Concerns\SharesSupplierPdo;

uses(DatabaseTransactions::class, SharesSupplierPdo::class);

/** Создаёт активный read-ключ, возвращает plain-ключ. */
function makeApiKey(int $tenantId, int $userId, array $over = []): string
{
    $plain = 'lpkapi_'.Str::random(48);
    ApiKey::create(array_merge([
        'tenant_id' => $tenantId,
        'user_id' => $userId,
        'name' => 'test',
        'key_hash' => Hash::make($plain),
        'key_prefix' => substr($plain, 0, 10),
        'scopes' => ['read'],
        'expires_at' => now()->addYear(),
        'is_active' => true,
        'created_at' => now(),
    ], $over));

    return $plain;
}

test('валидный ключ → 200 и только свои сделки', function () {
    $tenantA = Tenant::factory()->create();
    $userA = User::factory()->create(['tenant_id' => $tenantA->id]);
    $tenantB = Tenant::factory()->create();

    Deal::factory()->count(2)->create(['tenant_id' => $tenantA->id, 'received_at' => now()]);
    Deal::factory()->create(['tenant_id' => $tenantB->id, 'received_at' => now()]);

    $key = makeApiKey($tenantA->id, $userA->id);

    $r = $this->getJson('/api/v1/deals', ['Authorization' => "Bearer {$key}"]);

    $r->assertOk();
    expect($r->json('data'))->toHaveCount(2);
});

test('нет заголовка → 401', function () {
    $this->getJson('/api/v1/deals')->assertStatus(401);
});

test('неверный ключ → 401', function () {
    $tenant = Tenant::factory()->create();
    $user = User::factory()->create(['tenant_id' => $tenant->id]);
    $key = makeApiKey($tenant->id, $user->id);

    // Тот же префикс, но изменённый хвост — Hash::check не пройдёт.
    $bad = substr($key, 0, 10).str_repeat('x', strlen($key) - 10);

    $this->getJson('/api/v1/deals', ['Authorization' => "Bearer {$bad}"])->assertStatus(401);
});

test('просроченный ключ → 401', function () {
    $tenant = Tenant::factory()->create();
    $user = User::factory()->create(['tenant_id' => $tenant->id]);
    $key = makeApiKey($tenant->id, $user->id, ['expires_at' => now()->subDay()]);

    $this->getJson('/api/v1/deals', ['Authorization' => "Bearer {$key}"])->assertStatus(401);
});

test('неактивный ключ → 401', function () {
    $tenant = Tenant::factory()->create();
    $user = User::factory()->create(['tenant_id' => $tenant->id]);
    $key = makeApiKey($tenant->id, $user->id, ['is_active' => false]);

    $this->getJson('/api/v1/deals', ['Authorization' => "Bearer {$key}"])->assertStatus(401);
});

test('last_used_at обновляется после успешного запроса', function () {
    $tenant = Tenant::factory()->create();
    $user = User::factory()->create(['tenant_id' => $tenant->id]);
    $key = makeApiKey($tenant->id, $user->id, ['last_used_at' => null]);

    $this->getJson('/api/v1/deals', ['Authorization' => "Bearer {$key}"])->assertOk();

    $row = ApiKey::where('tenant_id', $tenant->id)->latest('id')->first();
    expect($row->last_used_at)->not->toBeNull();
});

test('since-фильтр отсекает старые сделки', function () {
    $tenant = Tenant::factory()->create();
    $user = User::factory()->create(['tenant_id' => $tenant->id]);

    Deal::factory()->create(['tenant_id' => $tenant->id, 'received_at' => now()]);
    Deal::factory()->create(['tenant_id' => $tenant->id, 'received_at' => now()->subDays(10)]);

    $key = makeApiKey($tenant->id, $user->id);
    $since = now()->subDays(2)->toDateString();

    $r = $this->getJson("/api/v1/deals?since={$since}", ['Authorization' => "Bearer {$key}"]);

    $r->assertOk();
    expect($r->json('data'))->toHaveCount(1);
});
```

- [ ] **Step 2: Прогнать — все RED**

Run: `composer --working-dir=app test -- tests/Feature/Api/V1/PublicDealsApiTest.php`
Expected: тесты падают (роут `/api/v1/deals` не существует → 404; assertOk/assertStatus(401) не совпадают). Это подтверждает отсутствие реализации.

- [ ] **Step 3: Commit (тест)**

```bash
git add app/tests/Feature/Api/V1/PublicDealsApiTest.php
LEFTHOOK=0 git commit -m "test(G6): приёмка публичного API сделок (RED)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

---

## Task 2: Middleware ApiKeyAuth + алиас

**Files:**
- Create: `app/app/Http/Middleware/ApiKeyAuth.php`
- Modify: `app/bootstrap/app.php`

- [ ] **Step 1: Создать middleware**

```php
<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use App\Models\ApiKey;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Symfony\Component\HttpFoundation\Response;

/**
 * Аутентификация публичного API по ключу тенанта (G6).
 *
 * Ключ — `Authorization: Bearer lpkapi_...`. В БД лежит bcrypt key_hash + key_prefix
 * (первые 10 символов). Ищем кандидатов по префиксу через pgsql_supplier (BYPASSRLS —
 * публичный роут не ставит tenant-GUC, под RLS api_keys вернул бы пусто), затем
 * Hash::check. Успех → tenant_id в request->attributes (api_tenant_id) + last_used.
 */
class ApiKeyAuth
{
    public function handle(Request $request, Closure $next): Response
    {
        $key = $this->bearer($request);
        if ($key === null || $key === '') {
            return response()->json(['message' => 'Требуется API-ключ.'], 401);
        }

        $prefix = substr($key, 0, 10);

        $candidates = ApiKey::on('pgsql_supplier')
            ->where('key_prefix', $prefix)
            ->where('is_active', true)
            ->where('expires_at', '>', now())
            ->get();

        $matched = null;
        foreach ($candidates as $candidate) {
            if (Hash::check($key, (string) $candidate->key_hash)) {
                $matched = $candidate;
                break;
            }
        }

        if ($matched === null) {
            return response()->json(['message' => 'Неверный или неактивный API-ключ.'], 401);
        }

        if (! in_array('read', (array) $matched->scopes, true)) {
            return response()->json(['message' => 'Недостаточно прав ключа.'], 403);
        }

        ApiKey::on('pgsql_supplier')->whereKey($matched->getKey())->update([
            'last_used_at' => now(),
            'last_used_ip' => $request->ip(),
        ]);

        $request->attributes->set('api_tenant_id', (int) $matched->tenant_id);

        return $next($request);
    }

    private function bearer(Request $request): ?string
    {
        $header = (string) $request->header('Authorization', '');
        if (str_starts_with($header, 'Bearer ')) {
            return trim(substr($header, 7));
        }

        return null;
    }
}
```

- [ ] **Step 2: Зарегистрировать алиас в bootstrap/app.php**

В `app/bootstrap/app.php` в начало (к существующим `use`) добавить:
```php
use App\Http\Middleware\ApiKeyAuth;
```
И в массив `$middleware->alias([...])` добавить строку:
```php
            'apikey' => ApiKeyAuth::class,
```
(рядом с `'tenant'` и `'saas-admin'`.)

- [ ] **Step 3: Type-check стиля (Pint, не падает на отсутствии роута)**

Run: `composer --working-dir=app pint -- app/Http/Middleware/ApiKeyAuth.php bootstrap/app.php`
Expected: `{"tool":"pint","result":"passed"}`.

- [ ] **Step 4: Commit (middleware)**

```bash
git add app/app/Http/Middleware/ApiKeyAuth.php app/bootstrap/app.php
LEFTHOOK=0 git commit -m "feat(G6): middleware ApiKeyAuth — аутентификация по API-ключу

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

---

## Task 3: Контроллер V1\DealsController + роут

**Files:**
- Create: `app/app/Http/Controllers/Api/V1/DealsController.php`
- Modify: `app/routes/web.php`

- [ ] **Step 1: Создать контроллер**

```php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Models\Deal;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

/**
 * Публичный read-API сделок тенанта (G6). Аутентификация — middleware ApiKeyAuth
 * (tenant_id в request->attributes['api_tenant_id']). Только сделки (deals), не
 * supplier_leads.
 */
class DealsController extends Controller
{
    public function index(Request $request): JsonResponse
    {
        $tenantId = (int) $request->attributes->get('api_tenant_id');

        $limit = max(1, min(500, (int) $request->query('limit', '100')));

        $since = trim((string) $request->query('since', ''));
        $sinceDt = null;
        if ($since !== '') {
            try {
                $sinceDt = Carbon::parse($since);
            } catch (\Throwable) {
                return response()->json(['message' => 'Невалидный since.'], 422);
            }
        }

        $cursorRaw = (string) $request->query('cursor', '');
        $cursor = null;
        if ($cursorRaw !== '') {
            $decoded = base64_decode($cursorRaw, true);
            $parsed = $decoded === false ? null : json_decode($decoded, true);
            if (! is_array($parsed) || ! isset($parsed['r'], $parsed['i'])) {
                return response()->json(['message' => 'Невалидный cursor.'], 422);
            }
            $cursor = ['r' => (string) $parsed['r'], 'i' => (int) $parsed['i']];
        }

        [$rows, $next] = DB::transaction(function () use ($tenantId, $limit, $sinceDt, $cursor) {
            DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);

            $query = Deal::query()
                ->where('tenant_id', $tenantId)
                ->with('project:id,name');

            if ($sinceDt !== null) {
                $query->where('received_at', '>=', $sinceDt);
            }
            if ($cursor !== null) {
                $query->whereRaw('(received_at, id) < (?, ?)', [$cursor['r'], $cursor['i']]);
            }

            $rows = $query->orderByDesc('received_at')->orderByDesc('id')
                ->limit($limit + 1)->get();

            $hasNext = $rows->count() > $limit;
            if ($hasNext) {
                $rows = $rows->slice(0, $limit)->values();
            }

            $next = null;
            if ($hasNext && $rows->isNotEmpty()) {
                $last = $rows->last();
                $next = base64_encode((string) json_encode([
                    'r' => $last->received_at?->toIso8601String(),
                    'i' => $last->id,
                ]));
            }

            return [$rows, $next];
        });

        return response()->json([
            'data' => $rows->map(fn (Deal $d) => [
                'id' => $d->id,
                'received_at' => $d->received_at?->toIso8601String(),
                'phone' => $d->phone,
                'contact_name' => $d->contact_name,
                'city' => $d->city,
                'status' => $d->status,
                'project' => $d->project?->name,
            ])->all(),
            'next_cursor' => $next,
        ]);
    }
}
```

- [ ] **Step 2: Добавить роут в routes/web.php**

В `app/routes/web.php` после группы API-ключей (`Route::...prefix('/api/api-keys')...`) добавить:

```php
// G6: публичный read-API сделок по API-ключу (middleware apikey — без sanctum/tenant).
Route::middleware('apikey')->prefix('/api/v1')->group(function () {
    Route::get('/deals', 'App\Http\Controllers\Api\V1\DealsController@index')->name('api.v1.deals.index');
});
```

- [ ] **Step 3: Прогнать тест — все GREEN**

Run: `composer --working-dir=app test -- tests/Feature/Api/V1/PublicDealsApiTest.php`
Expected: 7 тестов PASS.

- [ ] **Step 4: Pint + commit**

```bash
composer --working-dir=app pint -- app/Http/Controllers/Api/V1/DealsController.php routes/web.php
git add app/app/Http/Controllers/Api/V1/DealsController.php app/routes/web.php
LEFTHOOK=0 git commit -m "feat(G6): контроллер + роут GET /api/v1/deals (публичный read-API сделок)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

---

## Task 4: Регрессия + живой smoke (curl)

**Files:** (нет правок — проверки)

- [ ] **Step 1: Регрессия API-ключей и сделок**

Run: `composer --working-dir=app test -- tests/Feature/Api/V1/PublicDealsApiTest.php tests/Feature/ApiKeysTest.php`
Expected: GREEN (если `ApiKeysTest.php` существует; иначе прогнать только PublicDealsApiTest — новый middleware/роут изолированы и сессионный ApiKeyController не тронут).

- [ ] **Step 2: Живой smoke на dev (опционально, по желанию владельца)**

1. В браузере (сессия клиента) `POST /api/api-keys/regenerate` ИЛИ через UI «Настройки → API и Webhook» получить plain-ключ.
2. `curl -s -H "Authorization: Bearer <plain>" http://127.0.0.1:8000/api/v1/deals` → JSON `{data:[...], next_cursor:...}` со сделками этого тенанта.
3. `curl -s http://127.0.0.1:8000/api/v1/deals` (без ключа) → `401 {"message":"Требуется API-ключ."}`.

---

## Self-Review (выполнено автором плана)

**1. Spec coverage:**
- §3.1 middleware (Bearer, prefix-поиск через pgsql_supplier, Hash::check, scope, last_used, attribute) → Task 2 ✓
- §3.2 контроллер (tenant attr, limit/since/cursor, SET LOCAL, keyset, форма ответа) → Task 3 ✓
- §3.3 роут /api/v1 + алиас → Task 2 Step 2 + Task 3 Step 2 ✓
- §4 ошибки (401/403/422) → middleware (401/403) + контроллер (422 cursor/since) ✓
- §5 приёмка (7 пунктов) → Task 1 ✓
- §6 YAGNI (только сделки, read, без cost/UI) → соблюдено ✓

**2. Placeholder scan:** плейсхолдеров нет; весь код приведён целиком.

**3. Type consistency:** `api_tenant_id` (request attribute) пишется в Task 2 и читается в Task 3 одинаково; `makeApiKey($tenantId,$userId,$over)` сигнатура едина; `'apikey'` алиас совпадает в bootstrap (Task 2) и роуте (Task 3); поля ответа (`id/received_at/phone/contact_name/city/status/project`) — из колонок `deals`, выверенных по схеме.
