# ТЗ: бекенд Trans-Atlas, Спринт 0

Версія 1.0 від 10 вересня 2026. Базовий стан фронтенду: `Dymytrii524/TA`, `main` @ `f6efc65`.

Це не описове ТЗ. Кожне твердження нижче підкріплене артефактом, який перевіряється машиною, і прогоном, який уже виконано. Розбіжність між документом і артефактом вважається дефектом документа.

---

## 1. Мета і межі

**Мета.** Перетворити демонстраційний SPA на застосунок із серверним джерелом істини: акаунти компаній, персистентні заявки, довідники в БД, журнал подій. Без цього ані верифікація, ані ingest, ані стрічка реального часу неможливі — вони не мають де зберігати стан.

**Входить у Спринт 0**

1. PostgreSQL 18 + PostGIS: схема, міграції, довідники з реєстрів SPA.
2. Автентифікація: реєстрація компанії, вхід, ротація refresh-токенів, підтвердження email, ролі.
3. CRUD заявок і стрічка з фільтрами, які дослівно повторюють поведінку `filteredListings()`.
4. Імпорт наявних 10 773 демо- і тестових заявок у БД без втрати даних.
5. Append-only журнал подій.
6. Розгортання, міграції, бекапи, моніторинг.

**Не входить свідомо**

| Поза межами | Чому | Куди перенесено |
|---|---|---|
| Модуль SEARCH (маршрути, магістраль→фідери, `POST_DRONE`) | Має власний REST-контракт і власний CI | Окремий етап |
| Верифікація контрагента (KYB, реєстри, дельта-моніторинг) | Напрям A плану | Тижні 3–7 |
| Ingest-агент | Напрям B плану | Тижні 4–8 |
| SSE-стрічка, push, PWA | Напрям C плану | Тижні 6–10 |
| Ставки, торг, ескроу, розрахунки | Межа модулів A.1.3 рамкового ТЗ | Після перших угод |

У схемі Спринту 0 залишені точки розширення під ці напрями: `companies.verification_level`, `listings.source` зі значеннями `ingest_*`, партиціонований `event_log`.

---

## 2. Стек і середовище

| Компонент | Рішення | Обґрунтування |
|---|---|---|
| Мова | TypeScript, Node.js 24 LTS | Рішення попереднього етапу; одна мова від схеми до інтерфейсу |
| Каркас | NestJS | Модульність, DI, готова інтеграція валідації та OpenAPI |
| БД | PostgreSQL 18 + PostGIS 3.6 | Перевірено на цих версіях; PostGIS потрібен уже зараз для країн і відстаней, а далі — для геофенсів напряму C |
| Кеш і черга | Redis 7 | Ідемпотентність, ліміти запитів, надалі — шина подій стрічки |
| Міграції | node-pg-migrate або Flyway, тільки вперед | Кожна міграція має бути застосовною до продової бази без ручних кроків |
| Пошта | SMTP-провайдер із доменною автентифікацією (SPF, DKIM, DMARC) | Підтвердження email і скидання пароля |
| Спостережуваність | Структурні JSON-логи, `/api/v1/health`, метрики Prometheus | Мінімум, без якого продакшн не запускається |

Наявний `server/` (погода, кордони, довідник, Q&A) **не переписується**. Він лишається окремим сервісом і отримує спільний домен та спільний формат помилок; його переїзд у NestJS — окрема задача поза Спринтом 0.

---

## 3. Модель даних

Повний DDL: `db/migrations/001_init.sql`. Нижче — рішення, які він кодує.

### 3.1 Довідники — джерело істини не вигадується

`cities`, `cargo_types`, `currencies` сідяться **з `index.html`**, а не з нового списку. Причина: 7 120 тестових заявок і весь фронтенд уже прив'язані до цих ідентифікаторів. Витягування виконує `tools/extract_registries.py`; у CI перегенерація має давати побайтово той самий `seed/`, інакше збірка падає.

Фактично витягнуто: **175 міст** (europe 42, asia 36, africa 26, america 48, australia 23), **9 типів вантажу**, **32 валюти** (29 фіатних, 3 стейблкоїни).

### 3.2 Ключові інваріанти на рівні БД

Інваріант, який тримає лише код застосунку, рано чи пізно буде порушений імпортом або міграцією. Тому:

| Інваріант | Механізм |
|---|---|
| `mode='multi'` ⇒ `components` містить ≥ 2 плеча, і лише базові види | `CHECK listings_components_rule` |
| `mode<>'multi'` ⇒ `components IS NULL` | той самий `CHECK` |
| `mode='drone'` ⇔ існує рядок у `listing_drone_details` | відкладений `CONSTRAINT TRIGGER` |
| Місто відправлення ≠ місто призначення | `CHECK listings_origin_ne_destination` |
| `is_cross_border` не редагується вручну | `GENERATED ALWAYS AS ... STORED` |
| Рівень верифікації вище L0 неможливий без дати перевірки | `CHECK companies_verified_needs_date` |
| У компанії рівно один власник | частковий `UNIQUE INDEX` по `role='owner'` |
| Email унікальний без урахування регістру | тип `citext` |
| Журнал подій не редагується застосунком | `REVOKE UPDATE, DELETE` для ролі застосунку |

### 3.3 Гроші й одиниці

- Гроші — `NUMERIC(14,2)` у БД і **десятковий рядок** в API. Тип `number` для сум заборонений на всіх рівнях.
- Вага — завжди кілограми (`weight_kg NUMERIC(14,3)`). Legacy-поле `weight` подається в тоннах для всіх видів транспорту, крім `mode='drone'`, де воно вже в кілограмах і супроводжується `weightUnit:"kg"`. Це знімає давню двозначність тонн і кілограмів у тексті ТЗ (розбіжність D-2).
- Валюта ставки й валюта оплати розділені на рівні моделі вже зараз: `price_currency` — це валюта заявки, а не валюта майбутнього платежу. Поля розрахунків з'являться в окремій таблиці напряму розрахунків.

---

## 4. Контракт API

Повний контракт: `api/openapi.yaml` (OpenAPI 3.1, 15 шляхів). Схема заявки винесена в `schemas/listing.schema.json` і підключається через `$ref` — один опис для API, БД та імпортера.

### 4.1 Наскрізні правила

| Правило | Деталь |
|---|---|
| Версіонування | Префікс `/api/v1`. Несумісна зміна = новий префікс, а не мовчазна правка |
| Помилки | RFC 9457 `application/problem+json` з масивом `errors[]`, де кожен елемент має JSON Pointer на поле |
| Ідемпотентність | Заголовок `Idempotency-Key` обов'язковий для `POST /listings`; повтор із іншим тілом дає 409 |
| Пагінація | Курсорна: `limit` (типове 30 = `PAGE_SIZE` у SPA) і `cursor`. Зміщення сторінок не підтримується — на 10 тисячах заявок воно дає дублі й пропуски |
| Час і дати | RFC 3339 UTC; дати — `YYYY-MM-DD` |
| Ліміти | Логін 10/хв на IP, реєстрація 5/год на IP, `POST /listings` 60/год на компанію, `contact` 100/добу на користувача |
| CORS | Білий список доменів; облікові дані передаються заголовком `Authorization`, не кукою третьої сторони |

### 4.2 Паритет фільтрів

Стрічка `GET /api/v1/listings` мусить давати той самий результат, що й `filteredListings()` сьогодні. Відповідність зафіксована в описах параметрів і перевіряється автоматично.

| `state.filters` у SPA | Параметр API |
|---|---|
| `mode` | `mode` |
| `multiModes` | `components` |
| `from` | `origin_city_id` |
| `to` | `destination_city_id` |
| `fromCountry` | `origin_country` |
| `toCountry` | `destination_country` |
| `borderScope` | `border_scope` |
| `currency` | `currency` |

Додано понад наявний набір: `cargo_type_id`, `date_from`, `date_to`, `company_id`, `status`, `sort`.

---

## 5. Автентифікація і безпека

| Вимога | Значення |
|---|---|
| Хешування пароля | argon2id, `m=64 MiB`, `t=3`, `p=1`; параметри зберігаються в самому хеші |
| Мінімальна довжина пароля | 10 символів; перевірка за списком скомпрометованих паролів; складнісні правила не застосовуються |
| Access-токен | JWT, 15 хвилин, підпис EdDSA, `kid` для ротації ключів |
| Refresh-токен | 30 днів, зберігається лише як SHA-256; **ротація з родиною**: повторне використання відкликаного токена вбиває всю родину і завершує всі сесії користувача |
| Блокування | 10 невдалих спроб → блок на 15 хвилин (`users.failed_logins`, `users.locked_until`) |
| Підтвердження email | Одноразовий токен, 24 години. До підтвердження заявки не публікуються, перегляд дозволений |
| Ролі | `owner`, `manager`, `viewer`; редагувати заявку може лише її компанія |
| Секрети | Лише зі змінних середовища; у репозиторії тільки `.env.example` |
| Персональні дані | Мінімізація: телефон і email контактної особи розкриваються лише авторизованому користувачу і кожне розкриття пишеться в журнал |

---

## 6. Міграція наявних даних

Імпортер: `tools/import_legacy.py`. Вхідний формат описано в `schemas/listing-legacy.schema.json`.

**Правила перетворення**

1. `weight` (тонни) → `weight_kg` множенням на 1000; для `mode='drone'` значення переноситься як є.
2. Гроші стають десятковим рядком; float не потрапляє в БД у жодній точці конвеєра.
3. Рядок `company` стає сутністю `companies` з детермінованим UUID v5 від нормалізованої назви, рівень `L0`.
4. Цілий `id` зберігається як `legacy_id` (унікальний); первинним ключем стає UUID v5 від нього — повторний запуск імпорту не створює дублів.
5. Поле `continent` із тестових даних **не переноситься**: континент визначає місто. Дублювати джерело істини не можна.
6. Континентальні агрегати `listings-<continent>.json` пропускаються — вони містять ті самі записи, що й пофайлові набори за видами транспорту.

**Фактичний результат прогону:** 10 773 заявки (3 653 із `index.html` плюс 7 120 із `test-data/`), з них 1 262 дронові, 9 949 міжнародних. Втрат немає.

---

## 7. Журнал подій

`event_log` партиціонований поквартально за `recorded_at`, первинний ключ `(recorded_at, id)`.

Типи подій Спринту 0: `listing_view`, `listing_impression`, `search`, `contact_reveal`, `filter_change`, `listing_created`, `listing_updated`, `listing_closed`.

Клієнт передає лише тип події та об'єкт; актора, час отримання та IP проставляє сервер. Роль застосунку не має прав `UPDATE` і `DELETE` — журнал справді append-only, а не «за домовленістю».

Це і є той набір, на якому пізніше навчатимуться матчинг і прогноз ставок. Тому він ведеться з першого дня, а не з дня, коли ці функції поставлять у план.

---

## 8. Зміни на фронтенді

`index.html` лишається односторінковим і без збірки. Змінюється лише шар доступу до даних:

1. `LISTINGS` перестає бути літералом і стає результатом `GET /api/v1/listings`; `loadExternalListings()` і `LISTINGS_FEED` замінюються курсорною пагінацією.
2. `getUsers()`, `setUsers()`, `saveUser()` видаляються. `localStorage` лишається лише для мови, вибраного виду транспорту та інших втрачуваних станів UI.
3. Форма модального вікна отримує третій режим — публікацію заявки.
4. Помилки показуються з `problem+json`, поле в помилці підсвічується за JSON Pointer.
5. Перемикач `API_BASE`: за відсутності бекенду сайт чесно повідомляє про це, як уже роблять вкладки погоди й кордонів. Вигаданих даних немає в жодному режимі.

Перевірки UI, які вже є (`state.heroMode`, 6/6 режимів, лічильники потоків), мають лишитися зеленими після переходу — це умова приймання.

---

## 9. Нефункціональні вимоги

| Показник | Ціль |
|---|---|
| Стрічка з фільтрами, p95 | ≤ 200 мс на 100 тис. заявок |
| Публікація заявки, p95 | ≤ 300 мс |
| Доступність | 99.5% на місяць |
| Бекап | Щодоби повний, WAL безперервно; відновлення перевіряється щомісяця реальним прогоном |
| Логи | JSON, з `request_id`; персональні дані не логуються |
| Міграції | Тільки вперед, без ручних кроків, застосовуються автоматично при деплої |
| Оточення | `dev`, `staging`, `prod`; на `staging` — копія схеми та знеособлені дані |

---

## 10. Фактичні прогони

Виконано в цій сесії, не описано.

| Прогін | Що перевіряв | Результат |
|---|---|---|
| `tools/extract_registries.py` | Витягування реєстрів із `index.html` | 175 міст, 9 вантажів, 32 валюти, порожніх полів немає |
| `ci/check_backend_contract.py` | 8 груп перевірок артефактів | **15 із 15 пройдено** |
| ↳ A | 10 773 legacy-заявки проти legacy-схеми | 10 773 / 10 773 валідні |
| ↳ B | Посилання на міста, вантажі, валюти, режими | 0 розбіжностей |
| ↳ C | Канонічні записи після імпортера | 10 773 / 10 773 валідні; тонни й кілограми звірені окремо |
| ↳ D | OpenAPI: розбір і 20 локальних `$ref` | 15 шляхів, 0 нерозв'язаних |
| ↳ E | 5 перерахувань DDL проти реєстрів і схеми | збігаються |
| ↳ F | Паритет 8 фільтрів SPA і параметрів API, `limit` = `PAGE_SIZE` | 0 без відповідника |
| ↳ G | Гроші й вага — рядки, не float | 0 порушень |
| ↳ H | 10 навмисних мутацій | виявлено 10 / 10 |
| `ci/acceptance_db.py` | Жива PostgreSQL 18.6 + PostGIS 3.6 | **25 із 25 пройдено** |
| ↳ міграція | `001_init.sql` на чистій базі | 0.31 с |
| ↳ імпорт | 10 773 заявки, 1 262 дрон-деталі | 1.0 с, без втрат |
| ↳ негативні | 13 навмисних порушень інваріантів | усі відхилені базою |
| ↳ журнал | партиціонування і заборона `DELETE` для ролі застосунку | працює |

**Дві реальні поламки, знайдені саме прогонами, а не оглядом:**

- `event_log` мав `bigserial PRIMARY KEY`; PostgreSQL відхилив міграцію, бо первинний ключ партиціонованої таблиці зобов'язаний містити ключ секціонування. Виправлено на `PRIMARY KEY (recorded_at, id)`.
- Схема грошей дозволяла три знаки після коми, тоді як `NUMERIC(14,2)` у БД — лише два. Мутація «1450.005» не була виявлена; додано окремий тип `money` з двома знаками, а `decimal` лишився для ваги й об'єму.

---

## 11. Приймальні критерії Спринту 0

1. Заявка, створена в одному браузері, видима з іншого пристрою після повного очищення локального сховища.
2. Усі 10 773 наявні заявки в БД і проходять ті самі фільтри, пагінацію та панель потоків без змін у логіці UI.
3. `POST /api/v1/listings` із повторним `Idempotency-Key` і тим самим тілом дає ту саму відповідь, з іншим тілом — 409.
4. Повторне використання відкликаного refresh-токена завершує всі сесії користувача.
5. Роль застосунку не може виконати `UPDATE` чи `DELETE` над `event_log`.
6. Усі чотири прогінні job-и конвеєра бекенду й `backend-gate` — обов'язкові status checks у правилі захисту гілки (розділ 11.1).
7. Негативні PR блокуються: вимкнення валідації схеми в `POST /listings`; `mode='multi'` без `components`; float у грошовому полі; розходження `seed/` з `index.html`; перейменування job без оновлення `ruleset-contract.json`.
8. Наявні контрактні перевірки (`Контракт`, i18n, схеми, UI-режими) лишаються зеленими.

---

### 11.1 Обов'язкові перевірки в правилі захисту гілки

Правило `ci/ruleset-contract.json` вимагає десять перевірок від двох конвеєрів. Кожен конвеєр має власний gate — job, що виконується завжди (`if: always()`) і падає, якщо впав будь-який job, від якого він залежить. Gate потрібен тому, що job, який не стартував, не повідомляє статусу взагалі: без нього провал перетворюється на нескінченне очікування замість блокування.

| Конвеєр | Job | Перевірка в правилі |
| --- | --- | --- |
| `ci/github-actions-contract.yml` | `schema` | Схема і сценарії T16-T27 |
| | `i18n` | Валютні та мовні сценарії C1-C13, L1-L12 |
| | `proto` | Контракт proto модуля SEARCH |
| | `ui` | UI-сценарії U1-U8 вибору виду транспорту |
| | `contract-gate` | `contract-gate` |
| `ci/backend-contract.yml` | `backend-contract` | Контракт бекенду Спринту 0 |
| | `backend-db` | Приймальні тести БД Спринту 0 |
| | `verification-contract` | Контракт модуля верифікації |
| | `verification-db` | Інваріанти верифікації |
| | `backend-gate` | `backend-gate` |

Кожен файл `ci/*.yml` — дзеркало: GitHub виконує лише `.github/workflows/`, тому обидві копії мусять збігатися побайтово (`contract.yml`, `backend.yml`, `source-probe.yml`). За цим стежить правило R14.

`ci/source-probe.yml` (job `sources`, живий зонд VIES, KRS, білого списку VAT, реєстру санкцій) свідомо **не** входить до обов'язкових: недоступність чужого сервісу — стан зовнішнього світу, а не дефект pull request. Він працює за розкладом `17 5 * * *` і вручну, а результат зберігає артефактом `probe.json`.

### 11.2 Єдиний дозволений виняток

У `bypass_actors` правила стоїть рівно один запис — роль **Repository admin** (`actor_type: RepositoryRole`, `actor_id: 5`, `bypass_mode: always`).

Причина технічна, а не організаційна: GitHub не дозволяє схвалити власний pull request, тож правило з вимогою одного схвалення і порожнім списком винятків заблокувало б єдиного власника репозиторію назавжди. Виняток знімає вимогу рев'ю, але не скасовує прогону конвеєрів: перевірки все одно виконуються й видні в pull request.

Щойно в репозиторії з'явиться другий розробник із правом рев'ю, запис слід вилучити. Ролі `write` і `maintain`, команди й зовнішні додатки в списку винятків недопустимі жодного разу.

Цей розділ читає правило R13: якщо у `bypass_actors` з'явиться виняток, не описаний тут, прогін впаде.

Узгодженість правила з конвеєрами перевіряє `ci/check_ruleset.py`: чотирнадцять правил R1–R14 і 35 навмисних мутацій. Правила R11 (перевірка названа двічі), R12 (job перелічений у `needs` gate-а, але його результат не звіряється) R13 (список винятків) і R14 (дзеркало ci/ розійшлося з робочим конвеєром у .github/workflows) додано разом із конвеєром бекенду — жодну з цих помилок не видно оком у діффі, але кожна тихо знімає обов'язковість.

---

## 12. Склад артефактів

```
sprint-0-backend/
  ТЗ-бекенд-Спринт-0.md          — цей документ
  requirements.txt               — залежності перевірок
  api/openapi.yaml               — контракт API v1, 15 шляхів
  schemas/listing.schema.json    — канонічна заявка (API + БД)
  schemas/listing-legacy.schema.json — вхідний формат імпортера
  db/migrations/001_init.sql     — схема, інваріанти, партиції, права
  seed/{cities,cargo_types,currencies,enums}.json — реєстри з index.html
  tools/extract_registries.py    — генератор seed
  tools/import_legacy.py         — читання й перетворення legacy-заявок
  ci/check_backend_contract.py   — 8 груп перевірок артефактів
  ci/acceptance_db.py            — 25 перевірок на живій БД
```

Конвеєри лежать у спільному каталозі репозиторію, а не всередині `sprint-0-backend/`,
бо `ci/check_ruleset.py` читає їх саме звідти й тримає єдине джерело істини:

```
ci/backend-contract.yml          — 4 прогінні job + backend-gate (обов'язкові перевірки)
ci/source-probe.yml              — живий зонд реєстрів за розкладом, не обов'язковий
ci/ruleset-contract.json         — правило захисту гілки, 9 обов'язкових перевірок
ci/check_ruleset.py              — R1-R14 і 35 мутацій узгодженості правила з конвеєрами
ci/pr_simulation.py              — 19 сценаріїв pull request на справжніх git-гілках
```

---

## 13. Наступні кроки

1. Рішення про хостинг бекенду — воно й досі відкрите і блокує календар.
2. Один раз застосувати оновлений `ci/ruleset-contract.json` до `main` **після** того, як нові job-и хоч раз відпрацюють на `main`: правило, що вимагає ще не бачений GitHub статус, залишає pull request у вічному очікуванні.
3. Реалізувати NestJS-модулі `auth`, `companies`, `listings`, `reference`, `events` під уже зафіксований `openapi.yaml`.
4. Перевести фронтенд на API за прапорцем `API_BASE`, зберігши чесний офлайн-режим.
