# Mindfly API v2 — гайд для фронтенду

Це не «той самий API за новою адресою». `/api2` — **новий REST-інтерфейс**.
Старий `/api` (flat-file PHP) **лишається робочим** і не змінюється — мігруйте
поекранно, у своєму темпі.

Базовий шлях: `https://mindfly.com.ua/api2`

---

## 1. Що змінилося глобально (читати обов'язково)

### 1.1 Формат відповіді — без обгортки, лише `error` як дискримінатор

**Було:** кожен endpoint повертав «голий» JSON, форма якого відрізнялась від
ручки до ручки. Помилка могла бути `{"error": "Wrong request"}`, або
`{"error": "Session Expired", "action": "logout"}`, або взагалі текст.

**Стало:**

```jsonc
// успіх — корисне навантаження прямо в тілі, без "data"
{ "tests": [...], "banners": [...] }

// помилка
{ "error": { "code": "SESSION_EXPIRED", "message": "…", "details": { … } } }
```

Успіх/помилка розрізняються **наявністю ключа `error`**, а не якоюсь обгорткою —
у 200/201/202 відповіді ключа `error` ніколи немає. Ендпоінти зі списком +
пагінацією кладуть пагінацію в сусідній ключ `meta` (напр. `{ "materials": [...], "meta": {...} }`),
без generic `"data"`.

- `204 No Content` — тіла немає взагалі (напр. після PATCH/DELETE).
- Гілкуйтеся **лише за `error.code`** (стабільний enum), ніколи за `message`
  (message — для логів / дебагу, може змінитись).
- `details` — контекст помилки (напр. `{ "until": 1735689600 }` для `PREMIUM_ONLY`,
  або `{ "email": ["The email field is required."] }` для `VALIDATION`).

### 1.2 HTTP-коди тепер справжні

| Ситуація | Було | Стало |
|---|---|---|
| Успіх | завжди `200` (навіть на створення / помилку) | `200` / `201 Created` / `202 Accepted` / `204 No Content` |
| Немає/протух токен | `200` або `401` з `{"error":"Session Expired"}` | `401` + `code: SESSION_EXPIRED` |
| Немає прав (роль) | `200`/`403` з `{"error":"Access denied"}` | `403` + `code: FORBIDDEN` |
| Не знайдено | `200` з `[]` / `{}` / `{"error":...}` | `404` + `code: NOT_FOUND` |
| Невалідні дані | `200` з `{"error":"Wrong request"}` | `422` + `code: VALIDATION` + `details` по полях |
| Конфлікт (slug зайнятий, промокод використаний) | `200` з рядком | `409` + `code: CONFLICT`/`PROMO_USED`/… |
| Тільки для Mindfly+ | `200` з `{"error":"premiumOnly","until":…}` | `402` + `code: PREMIUM_ONLY`, `details.until` |

### 1.3 Коди помилок (`error.code`)

`VALIDATION`, `SESSION_EXPIRED`, `UNAUTHENTICATED`, `FORBIDDEN`, `NOT_FOUND`,
`CONFLICT`, `EMAIL_TAKEN`, `INVALID_CREDENTIALS`, `EMAIL_UNVERIFIED`,
`HOMEWORK_EXPIRED`, `HOMEWORK_DISABLED`, `PREMIUM_ONLY`, `ANSWERS_HIDDEN`,
`PROMO_INVALID`, `PROMO_USED`, `PROMO_EXPIRED`, `SUBSCRIPTION_NONE`,
`PAYMENT_FAILED`, `RATE_LIMITED`, `UPLOAD_REJECTED`, `SERVER_ERROR`.

Спеціальний випадок: якщо бек повертає `401 SESSION_EXPIRED` — робіть logout
(раніше цю логіку тригерив `action: "logout"` в тілі).

### 1.4 Автентифікація

- Токен так само передається в заголовку `Authorization`. Тепер приймається
  і `Authorization: Bearer <token>`, і просто `Authorization: <token>`.
- Замість `?native=true` (перерахунок стріку на сьогодні) — `?fresh=true`.
- Логін/реєстрація/OAuth повертають `{ "accessToken": "...", "user": { … } }`
  (раніше токен лежав у полі `access_token` поряд з полями профілю).

### 1.5 Іменування

REST-ресурси замість дієслівних файлів:
`/api/study/getTest?slug=x` → `GET /api2/tests/x`.
Повна таблиця — нижче.

### 1.6 Рейт-ліміт

Загальний ліміт `120 запитів / хв / IP`. Перевищення → `429` + `code: RATE_LIMITED`.

---

## 2. Таблиця відповідностей (усі ручки)

### Auth

| Було `/api/...` | Стало `/api2/...` | Нотатки |
|---|---|---|
| `POST accounts/login` (form: email, password) | `POST auth/login` (JSON) | 401 `INVALID_CREDENTIALS` замість `{"error":"Invalid password"}` |
| `POST accounts/register` | `POST auth/register` (JSON: email, name, password, type) | `201`; 409 `EMAIL_TAKEN` |
| `GET/POST accounts/logout` | `POST auth/logout` | `204` завжди |
| `POST accounts/forgotPassword` | `POST auth/password/forgot` | `202` завжди (не розкриваємо, чи існує email) |
| `GET accounts/getResetStatus?token=` | `GET auth/password/reset/{token}` | `{ "valid": true }` |
| `POST accounts/resetPassword` (token, password) | `POST auth/password/reset` | `204`; 404 якщо лінк протух |
| `GET accounts/popupGoogle` | `GET auth/google` | 302 на Google |
| `GET accounts/googleLogin` (callback) | `GET auth/google/callback` | deep-link назад, `?step=&access_token=` як раніше |
| `POST accounts/googleMobileLogin` (serverAuthCode) | `POST auth/google/mobile` | `{ accessToken, isNew, user }` |
| `POST accounts/appleLogin` (identityToken, user, email?, givenName?) | `POST auth/apple` | те саме тіло |

### Профіль / «Я»

| Було | Стало | Нотатки |
|---|---|---|
| `GET accounts/getProfile?native=` | `GET me` (+ `?fresh=true`) | поля перейменовано (див. §3) |
| `GET payments/paymentsWorker` (дубль getProfile) | видалено | використовуйте `GET me` |
| `POST accounts/adjustSettings` (JSON preferences) | `PATCH me/settings` | те саме тіло `preferences`; `204` |
| `GET accounts/deleteAccount` | `DELETE me` | `204` |
| `POST accounts/connectDevice` (device_token) | `POST me/devices` (`deviceToken`) | `204` |
| `GET accounts/resetStats` | `DELETE me/results` | `204` |
| `POST accounts/makeRerun` (testID) | `POST me/tests/{testId}/rerun` | `204` |
| `GET accounts/getSaved` | `GET me/saved-tests` | масив останніх результатів по тестах |
| `GET accounts/getStats?version=v2&period=` | `GET me/stats?period=week\|month\|3months\|year` | **v1-формат видалено**, лишився лише v2 |
| `GET accounts/getStats` (v1) | — | видалено; переходьте на `me/stats` |
| `GET accounts/getNMTcalc?elected=` | `GET me/nmt-calculator?elective=` | `{ redirect }` |
| — | `GET me/insights?subject=` | **НОВЕ**: персональний аналіз болючих точок + план + misconceptions (див. ANALYTICS.md) |
| — | `GET me/mastery?subject=&dimension=` | **НОВЕ**: rolling-модель засвоєння по темах/позиціях/типах питань |
| — | `GET me/focus` / `PUT me/focus` | **НОВЕ**: дошка «над чим працювати» в кабінеті + pin/dismiss навичок |
| — | `GET me/misconceptions?subject=` | **НОВЕ**: чому обрано той чи інший дистрактор + патерн знань |

### Каталог / навчання

| Було | Стало | Нотатки |
|---|---|---|
| `GET study/getTests?subject=&type=` | `GET subjects/{subject}/tests?view=nmt\|zno\|my\|org\|topics` | `type` → `view`; відповідь `{ tests, banners }` |
| `GET study/getTopics?subject=` | `GET subjects/{subject}/topics` | масив дерева тем |
| `GET study/getMaterial?slug=` | `GET materials/{slug}` | 404 замість порожнього тіла |
| `GET study/getTest?slug=` / `?code=` | `GET tests/{slug}` / `GET tests/{slug}?code=` | premium-гейт → `402 PREMIUM_ONLY` (`details.until`); ДЗ протухло → `409 HOMEWORK_EXPIRED` |
| `GET study/getAnswers?slug=&code=` | `GET tests/{slug}/answers?code=` | `403 ANSWERS_HIDDEN`, якщо політика `after` |
| `GET study/getTable?slug=&code=` | `GET tests/{slug}/score-table` | таблиця балів НМТ або `[]` |
| `POST study/multiFetch?type=nmt\|theory` (масив slug у тілі) | `POST tests/bulk` (`{ slugs: [...], type }`) | |
| `GET study/getBlitzTest?subject=` | `GET blitz?subject=` | |
| `GET randomTest?subject=&limit=&type=` | `GET practice?subject=&limit=&type=` | |
| `POST study/reportQuestion` (testId, questionOrder, content) | `POST tests/{slug}/report` (`questionOrder`, `content`) | ідентифікуємо тест по slug, не по id; `202` |
| `GET study/getTestGen` | видалено | (порожня заглушка в легасі) |
| `GET study/idToSlug` | видалено | (неробоча функція) |

### Спроби (проходження тесту)

| Було | Стало | Нотатки |
|---|---|---|
| `POST accounts/saveResults` (form-data) | `POST attempts` (JSON) | єдина ручка: оцінює + зберігає, якщо доречно |
| `POST study/checkAnswers` | `POST attempts/check` | лише оцінка, нічого не зберігає |
| `POST accounts/syncOffline` (батч `saveResults`) | видалено | офлайн-черга просто повторює `POST attempts` по одному |
| `POST study/lmsEvaluate` (X-Internal-Secret) | `POST internal/attempts/evaluate` | service-to-service, не для браузера |

`POST attempts` тіло: `{ slug | code, userAnswers, userAnswers2?, testStart, testEnd,
elapsed?, name?, isFirstCompletion?, openedExplanations? }`.
Відповідь: `{ testResult, correctAnswers?, leaderboard?, elapsed }`
(`correctAnswers` — лише коли політика показу дозволяє).

**Симуляційне ДЗ** (`code` вказує на homework з `type: "simulation"`): передайте
`userAnswers` + `userAnswers2`. Відповідь додатково містить `testResult2` та
`correctAnswers2`. Обидві половини зберігаються як зв'язані записи; вчитель бачить
їх у `GET homeworks/{code}/results` у вигляді `{ part1, part2 }`.

**Телеметрія проходження** — `POST attempts` тепер також приймає `attemptId` (uuid),
`events[]`, `questionStats[]`, `clientMeta`, і повертає `attemptId`. Плюс
з'явились `POST attempts/start` та `POST attempts/{attemptId}/events` для
збереження мікроповедінки під час тесту. **Повний контракт — `ANALYTICS.md`.**
Це опційно для роботи тесту, але потрібно для персональних інсайтів.

Відповідь `POST attempts` тепер також містить `advice` — одну ненав'язливу
пораду («якщо виправляєшся — похвалить, інакше м'яко підсвітить патерн»), або `null`.

| Було | Стало |
|---|---|
| — | `POST attempts/start` → `{ attemptId }` |
| — | `POST attempts/{attemptId}/events` (батч подій, throttle 600/хв) |
| — | `GET homeworks/{code}/behavior` — карта вагань класу, дистрактори, архетипи (вчитель) |
| `GET homework/getAssigned` (тепер `.../results`) | + поля `attemptId`, `archetype`, `avgSecondsPerQuestion`, `guessRate`, `integrityRisk` на кожному submission |
| — | `GET homeworks/{code}/integrity` — хто ймовірно рандомив/обманював (агрегат + по кожному) |
| — | `GET homeworks/{code}/submissions/{attemptId}/integrity` — розбір однієї спроби + baseline учня |

### Редактор: staged-завантаження зображень

`POST editor/uploads` більше не пише одразу в прод-дерево. Він повертає
`{ id: "staged:<uuid>", previewUrl }`.

- Для зображення питання/варіанта — покладіть `"staged:<uuid>"` у поле `image`.
- Для зображення в rich-text — вставте `<img data-staged="<uuid>" src="{previewUrl}">`.
- На `PUT editor/tests/{slug}` бекенд промоутить лише ті staged-зображення, на які
  реально є посилання, у постійні URL і переписує посилання. Непов'язані staged-файли
  видаляються за 24 год.

### Домашні завдання

| Було | Стало |
|---|---|
| `POST homework/assignHw` | `POST homeworks` (role ≥ 1) → `201 { code }` |
| `GET homework/getAssignedList` | `GET homeworks` |
| `GET homework/getHomework?code=` | `GET homeworks/{code}` |
| `POST homework/editHw` (code + поля) | `PATCH homeworks/{code}` → `204` |
| `GET homework/deleteHw?code=` | `DELETE homeworks/{code}` → `204` |
| `GET homework/getAssigned?code=` | `GET homeworks/{code}/results` |
| `GET homework/getCompleted?code=` | `GET homeworks/{code}/completions` |
| `GET study/getTestStats?code=` | `GET homeworks/{code}/analytics` |

Помилки прав → `403 FORBIDDEN` (раніше `{"error":"Abchixba"}` / `Access denied`).

### Лідерборд / пошук

| Було | Стало |
|---|---|
| `GET accounts/leaderboard` | `GET leaderboard` (форма `{ week, month, ever }` без змін по суті) |
| `GET search/searchMaterials?search=&subject=&type=&year=&page=` | `GET search?q=&subject=&type=&year=&page=` — `search` → `q`; `{ materials, meta }` (пагінація в `meta`) |

### Оплати / підписка / промокоди

| Було | Стало | Нотатки |
|---|---|---|
| `GET payments/proceedPayment` | `POST subscription/checkout` | `201 { redirect }`; вже є підписка → `409` |
| `GET payments/cancelSubscription` | `DELETE subscription` | `{ adsfree }`; нема підписки → `409 SUBSCRIPTION_NONE` |
| `POST payments/paymentRecieve` (mono webhook) | `POST webhooks/mono` | без змін для фронта |
| `POST payments/activatePromo` (`{code}`) | `POST promo/redeem` (`{code}`) | помилки → `409 PROMO_INVALID/USED/EXPIRED` |
| `GET payments/validatePromo?code=` | `GET promo/{code}` | dry-run; `{ days }` або `409` |
| `POST payments/createPromo` | `POST promo` (role ≥ 2) | `201` |

### Редактор (role ≥ 1) — див. також §4 «Безпека редактора»

| Було | Стало | Нотатки |
|---|---|---|
| `POST editor/createTest` + `POST editor/postData` | `PUT editor/tests/{slug}` | єдина ідемпотентна ручка створення/заміни; тіло валідовано за схемою |
| `POST editor/deleteTest` | `DELETE editor/tests/{slug}` | `204` |
| `GET editor/getRawTest?slug=` | `GET editor/tests/{slug}` | |
| `GET editor/getRawMaterial?slug=` | `GET editor/materials/{slug}` | |
| `POST editor/postData` (contentType=theory) | `PUT editor/materials/{slug}` | |
| `GET editor/getMaterials` / `getAllTests` | `GET editor/tests?q=&subject=&type=&year=&page=&sortKey=&sortOrder=` | `search` → `q`; `{ materials, meta }` (пагінація в `meta`); сортування лише по whitelist (`editedAt/title/year/subject/type`) |
| `POST essentials/getFiles` / `TgetFiles` (upload) | `POST editor/uploads` (`image` — лише файл) | **staged-модель** (див. нижче); `201 { id: "staged:<uuid>", previewUrl }` |
| — | `GET editor/uploads/{uuid}` | стрім staged-зображення для прев'ю (лише автор/адмін) |
| — | `GET editor/topic-review?subject=&status=pending\|accepted\|rejected&page=` | **НОВЕ**: черга ІІ-запропонованих тегів «питання → тема», що чекають на підтвердження вчителем; `{ suggestions: [{ id, questionId, questionText, questionType, suggestedTopicId, suggestedTopicTitle, confidence, reasoning }], meta: { page, perPage, total } }`, сортовано за зростанням `confidence` |
| — | `POST editor/topic-review/{id}` (`{ status: "accepted"\|"rejected", topicId? }`) | **НОВЕ**: підтвердити (опційно підмінивши тему) або відхилити — `204` |

**`PUT editor/tests/{slug}` тепер підтримує перейменування слага.** `{slug}` в URL
лишається способом знайти тест (як і раніше) — але тепер це лише *поточний*
слаг, не єдиний ідентифікатор. Тіло опційно приймає власне поле `slug`
(`sometimes|nullable|string|max:100|regex:^[a-zA-Z0-9_-]+$`) з *новим* бажаним
значенням; якщо його не надіслати — слаг лишається таким, як у URL (поведінка
не змінилась для будь-кого, хто це поле ще не шле). Ідентичність тесту
(`id`, а з ним усі attempts/homework-посилання) зберігається через
перейменування — раніше цієї можливості не існувало взагалі (бекенд ігнорував
слаг з тіла, підставляючи URL-слаг напряму), а спроба обійти це через
create-заново створила б дублікат з новим `id`, осиротивши все, що вказувало
на оригінал. Конфлікт слага (уже зайнятий іншим тестом) → `409 CONFLICT`
(`details: { field: "slug" }`), а не сира 500 від унікального індексу в БД.

**`PUT editor/materials/{slug}` тепер уміє створювати матеріал, не лише
оновлювати.** Раніше цей ендпоінт БУКВАЛЬНО не мав create-гілки — робив лише
`UPDATE ... WHERE slug = ?`, тож теоретично матеріал (`materials` — окрема
таблиця від `tests`, не плутати з `type: "theory"` у самому тесті) неможливо
було створити через API взагалі, хай яке тіло не відправляй. Тепер: якщо
рядка з таким слагом ще нема — insert; якщо є — update по `id`, за тим самим
принципом, що й у `PUT editor/tests/{slug}` вище (включно з опційним
перейменуванням через поле `slug` у тілі й `409 CONFLICT` на зайнятий слаг).
Тіло: `title` (обов'язково), `content?`, `difficulty?` (0–10), `testSlug?`,
і **нове** `parentID?` — id теми з `topics`. `materials` не має власного
`subject`, каталог редактора (`GET editor/tests?...&type=theory`) визначає
його через `parentID → topics.subject`; без `parentID` матеріал збережеться
й буде доступний за прямим слагом, але не знайдеться при фільтрації по
предмету. Відповідь тепер `200 { id, slug }` замість порожнього `204`
(caller, що досі ігнорує тіло, як і раніше, не зламається).

**`materials` тепер має `userID`.** Раніше цієї колонки не було взагалі —
`EditorCatalogService` хардкодило `NULL as userID` для кожного рядка
matherials у своєму union з `tests`, тож `GET editor/tests?...&userId=`
("Мої матеріали" в редакторі) не міг знайти жодної теорії, хай хто б її не
створив. Тепер `PUT editor/materials/{slug}` проставляє `userID` автора
при створенні (не чіпає його на update), і каталог читає реальне значення.
Історичні рядки, створені до цієї міграції, лишаються з `userID: null` —
чесно, а не вигадана заднім числом атрибуція.

### Інше

| Було | Стало | Нотатки |
|---|---|---|
| `POST essentials/sendFeedback` | `POST feedback` (`message`, `email?`) | легасі був no-op; тепер лог |
| `POST essentials/mailHook` (X-Webhook-Token) | `POST webhooks/mail` | service-to-service |
| `GET essentials/updateSitemap` | artisan `mindfly:sitemap` (cron) | не HTTP |
| `GET accounts/sendNotification?key=` | `POST internal/notifications` (X-Internal-Secret) | ключ у query → заголовок |
| `GET adm.php` | **видалено** | небезпечна адмінка (див. SECURITY.md) |
| `test.php`, `test-headers.php`, `sendTestMessage.php`, `sitemap.xml.php` | **видалено** | дебаг-файли |
| `accounts/streakNotificationsWorker` | artisan `mindfly:streak-nudges` (cron) | |
| `payments/subscriptionWorker` | artisan `mindfly:renew-subscriptions` (cron) | |

---

## 3. Зміни у формі об'єктів

### Профіль (`GET me`)

| Було (`getProfile`) | Стало (`GET me`) |
|---|---|
| `access_token` (поряд з профілем) | винесено: `accessToken` окремим полем при логіні |
| `showAnswers` | `preferences.appearance.showAnswers` (у профілі більше не дублюється на top-level) |
| `regTime` | `registeredAt` |
| `lastLogin` | `lastLoginAt` |
| `adsfree` | `subscription` |
| решта (`id`, `name`, `email`, `role`, `type`, `streak`, `partner`, `workspace`, `preferences`) | без змін |

### Тест (`GET tests/{slug}`)

- Поля `questions[]`, `variants[]`, `variantsRight[]` — **без змін**.
- Кожне питання може мати опційне поле `topicId` (int, id з дерева тем `GET
  subjects/{subject}/topics`) — тема, за якою це питання класифіковано. `null`,
  якщо ще не проставлено. В `PUT editor/tests/{slug}` теж приймається опційно
  при створенні/редагуванні питання.
- Збережений результат користувача: завжди в полі `saved` (раніше міг бути
  `saved`, `testsaved`, `testdfsdfdfsfdsfsaved` — прибрано).
- Для ДЗ поле `slug` замінюється на `code`, `type: "hw"` — як раніше.

### Спроба (`POST attempts`)

- `testResult` — без змін (`score`, `skipped`, `skippedPoints`, `maxScore`,
  `testScore`, `maxTestScore`).
- `leaderboard` — `int|null` (кількість набраних балів, якщо результат зараховано).

---

## 4. Порядок міграції (рекомендація)

1. Додайте обгортку HTTP-клієнта: перевіряйте ключ `error` (якщо є — кидайте
   типізовану помилку з `error.code`, інакше тіло відповіді — і є ваш результат,
   без розпаковки), глобально ловіть `401 SESSION_EXPIRED` → logout.
2. Мігруйте по екранах: auth → профіль/налаштування → каталог → проходження
   тесту → ДЗ → підписка → редактор.
3. Старий `/api` вимикайте лише коли всі клієнти (web + iOS + Android) на `/api2`.
