docs: приведение документации в соответствие с backend
- Auth: register без токенов до верификации (userId, message, verificationCode)
- Auth: login — 429 RATE_LIMIT_EXCEEDED при lockout, user с avatarUrl
- Auth: verify-email — { userId, code }, без Bearer
- Auth: reset-password — поле newPassword
- Profile: stats — byStack, totalTestsTaken, totalQuestions, correctAnswers, accuracy
- Tests: POST /tests возвращает полный список questions
- Tests: answer — полный snapshot отвеченного вопроса
- Tests: history — offset-пагинация (limit/offset), формат { tests, total }
- Admin: GET /admin/questions/pending, POST approve/reject, PATCH для редактирования
- DB: email_verification_codes, password_reset_tokens; обновлена question_cache_meta
- Security: CORS_ORIGINS из env, CSP/COEP отключены
- LLM: LLM_FALLBACK_MODEL, LLM_RETRY_DELAY_MS
- Onboarding: правило .env.example, JWT_SECRET >= 32 символов
This commit is contained in:
220
api/contracts.md
220
api/contracts.md
@@ -35,34 +35,9 @@
|
||||
| 429 | `RATE_LIMIT_EXCEEDED` | Превышен лимит запросов |
|
||||
| 500 | `INTERNAL_ERROR` | Внутренняя ошибка сервера |
|
||||
|
||||
### Пагинация (cursor-based)
|
||||
### Пагинация
|
||||
|
||||
Для списков используется cursor-based пагинация на основе UUID v7 (сортируемый по времени).
|
||||
|
||||
Запрос:
|
||||
|
||||
```text
|
||||
GET /tests/history?limit=10&cursor=0192a8b0-...
|
||||
```
|
||||
|
||||
| Параметр | Тип | Обязательный | Описание |
|
||||
| ---------- | ----- | -------------- | ---------- |
|
||||
| limit | integer | нет | Количество записей (default 10, max 50) |
|
||||
| cursor | uuid | нет | ID последнего элемента предыдущей страницы |
|
||||
|
||||
Ответ всегда содержит:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [...],
|
||||
"pagination": {
|
||||
"nextCursor": "0192a8b0-...",
|
||||
"hasMore": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`nextCursor = null` и `hasMore = false` если это последняя страница.
|
||||
Для списков тестов и админ-очередей используется **offset-based** пагинация: `limit`, `offset`.
|
||||
|
||||
---
|
||||
|
||||
@@ -70,7 +45,7 @@ GET /tests/history?limit=10&cursor=0192a8b0-...
|
||||
|
||||
### POST /auth/register
|
||||
|
||||
Регистрация нового пользователя. Отправляет письмо с кодом подтверждения email.
|
||||
Регистрация нового пользователя. Отправляет письмо с кодом подтверждения email. **Токены не выдаются** до подтверждения email.
|
||||
|
||||
**Авторизация:** не требуется
|
||||
|
||||
@@ -94,25 +69,20 @@ GET /tests/history?limit=10&cursor=0192a8b0-...
|
||||
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"id": "0192a8b0-1234-7000-8000-000000000001",
|
||||
"email": "user@example.com",
|
||||
"nickname": "john_doe",
|
||||
"role": "free",
|
||||
"emailVerified": false,
|
||||
"createdAt": "2026-03-03T12:00:00.000Z"
|
||||
},
|
||||
"accessToken": "eyJhbGciOiJIUzI1NiIs..."
|
||||
"userId": "0192a8b0-1234-7000-8000-000000000001",
|
||||
"message": "Verification code sent to your email",
|
||||
"verificationCode": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
Set-Cookie: `refreshToken=<token>; HttpOnly; Secure; SameSite=Strict; Path=/api/v1/auth; Max-Age=604800`
|
||||
`verificationCode` — для dev/тестов; в production не отдаётся.
|
||||
|
||||
**Ошибки:**
|
||||
|
||||
| HTTP | Код | Когда |
|
||||
| ------ | ----- | ------- |
|
||||
| 409 | `EMAIL_TAKEN` | Email уже зарегистрирован |
|
||||
| 409 | `NICKNAME_TAKEN` | Никнейм уже занят |
|
||||
| 422 | `VALIDATION_ERROR` | Невалидные данные |
|
||||
| 429 | `RATE_LIMIT_EXCEEDED` | Более 3 регистраций с IP за час |
|
||||
|
||||
@@ -146,6 +116,7 @@ Set-Cookie: `refreshToken=<token>; HttpOnly; Secure; SameSite=Strict; Path=/api/
|
||||
"id": "0192a8b0-1234-7000-8000-000000000001",
|
||||
"email": "user@example.com",
|
||||
"nickname": "john_doe",
|
||||
"avatarUrl": null,
|
||||
"role": "free",
|
||||
"emailVerified": true
|
||||
},
|
||||
@@ -162,8 +133,7 @@ Set-Cookie: `refreshToken=<token>; HttpOnly; Secure; SameSite=Strict; Path=/api/
|
||||
| HTTP | Код | Когда |
|
||||
| ------ | ----- | ------- |
|
||||
| 401 | `INVALID_CREDENTIALS` | Неверный email или пароль |
|
||||
| 403 | `ACCOUNT_LOCKED` | Прогрессивный lockout (brute force) |
|
||||
| 429 | `RATE_LIMIT_EXCEEDED` | Превышен лимит попыток входа |
|
||||
| 429 | `RATE_LIMIT_EXCEEDED` | Прогрессивный lockout (brute force) или превышен лимит попыток входа |
|
||||
|
||||
---
|
||||
|
||||
@@ -218,16 +188,18 @@ Set-Cookie: новый `refreshToken` (ротация).
|
||||
|
||||
Подтверждение email по коду из письма.
|
||||
|
||||
**Авторизация:** Bearer token
|
||||
**Авторизация:** не требуется (Bearer не нужен)
|
||||
|
||||
**Request:**
|
||||
|
||||
| Поле | Тип | Обязательное |
|
||||
| ------ | ----- | -------------- |
|
||||
| userId | uuid | да |
|
||||
| code | string | да |
|
||||
|
||||
```json
|
||||
{
|
||||
"userId": "0192a8b0-1234-7000-8000-000000000001",
|
||||
"code": "123456"
|
||||
}
|
||||
```
|
||||
@@ -296,12 +268,12 @@ Set-Cookie: новый `refreshToken` (ротация).
|
||||
| Поле | Тип | Обязательное |
|
||||
| ------ | ----- | ------------- |
|
||||
| token | string | да |
|
||||
| password | string | да |
|
||||
| newPassword | string | да |
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "reset-token-from-email",
|
||||
"password": "newSecurePass456"
|
||||
"newPassword": "newSecurePass456"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -408,12 +380,25 @@ Set-Cookie: новый `refreshToken` (ротация).
|
||||
"selfLevel": "jun",
|
||||
"createdAt": "2026-03-03T12:00:00.000Z",
|
||||
"stats": {
|
||||
"testsCompleted": 42,
|
||||
"averageScore": 78
|
||||
"byStack": {
|
||||
"html": { "totalTestsTaken": 5, "totalQuestions": 50, "correctAnswers": 42, "accuracy": 84 },
|
||||
"css": { "totalTestsTaken": 3, "totalQuestions": 30, "correctAnswers": 24, "accuracy": 80 }
|
||||
},
|
||||
"totalTestsTaken": 8,
|
||||
"totalQuestions": 80,
|
||||
"correctAnswers": 66,
|
||||
"accuracy": 82.5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Структура stats:**
|
||||
- `byStack` — объект: ключ — стек (html, css и т.д.), значение — статистика по стеку (`totalTestsTaken`, `totalQuestions`, `correctAnswers`, `accuracy`)
|
||||
- `totalTestsTaken` — общее количество пройденных тестов
|
||||
- `totalQuestions` — общее количество отвеченных вопросов
|
||||
- `correctAnswers` — количество правильных ответов
|
||||
- `accuracy` — процент правильных ответов (0–100)
|
||||
|
||||
Не возвращает email, role, план — только публичная информация.
|
||||
|
||||
**Ошибки:**
|
||||
@@ -457,25 +442,38 @@ Set-Cookie: новый `refreshToken` (ротация).
|
||||
"level": "basic",
|
||||
"questionCount": 10,
|
||||
"status": "in_progress",
|
||||
"currentQuestion": 1,
|
||||
"startedAt": "2026-03-03T12:05:00.000Z",
|
||||
"timeLimitSeconds": null,
|
||||
"question": {
|
||||
"id": "0192a8b0-9abc-7000-8000-000000000003",
|
||||
"orderNumber": 1,
|
||||
"type": "single_choice",
|
||||
"questionText": "What does the <meta charset=\"UTF-8\"> tag do?",
|
||||
"options": [
|
||||
{ "key": "A", "text": "Sets the character encoding" },
|
||||
{ "key": "B", "text": "Sets the page title" },
|
||||
{ "key": "C", "text": "Links a stylesheet" },
|
||||
{ "key": "D", "text": "Defines a script" }
|
||||
]
|
||||
}
|
||||
"questions": [
|
||||
{
|
||||
"id": "0192a8b0-9abc-7000-8000-000000000003",
|
||||
"orderNumber": 1,
|
||||
"type": "single_choice",
|
||||
"questionText": "What does the <meta charset=\"UTF-8\"> tag do?",
|
||||
"options": [
|
||||
{ "key": "A", "text": "Sets the character encoding" },
|
||||
{ "key": "B", "text": "Sets the page title" },
|
||||
{ "key": "C", "text": "Links a stylesheet" },
|
||||
{ "key": "D", "text": "Defines a script" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "0192a8b0-9abc-7000-8000-000000000004",
|
||||
"orderNumber": 2,
|
||||
"type": "single_choice",
|
||||
"questionText": "Which HTML element is used for the largest heading?",
|
||||
"options": [
|
||||
{ "key": "A", "text": "<h6>" },
|
||||
{ "key": "B", "text": "<h1>" },
|
||||
{ "key": "C", "text": "<head>" },
|
||||
{ "key": "D", "text": "<header>" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
При создании теста вопросы копируются в `test_questions` (снепшот). Первый вопрос возвращается сразу.
|
||||
При создании теста возвращается **полный список всех вопросов** в `questions`. Вопросы копируются в `test_questions` (снепшот).
|
||||
|
||||
**Ошибки:**
|
||||
|
||||
@@ -533,7 +531,7 @@ Set-Cookie: новый `refreshToken` (ротация).
|
||||
|
||||
### POST /tests/:id/answer
|
||||
|
||||
Ответ на текущий вопрос. Возвращает следующий вопрос.
|
||||
Ответ на текущий вопрос.
|
||||
|
||||
**Авторизация:** Bearer token (только свой тест)
|
||||
|
||||
@@ -541,7 +539,7 @@ Set-Cookie: новый `refreshToken` (ротация).
|
||||
|
||||
| Поле | Тип | Обязательное | Описание |
|
||||
| ------ | ----- | -------------- | ---------- |
|
||||
| questionId | uuid | да | ID вопроса из `question` |
|
||||
| questionId | uuid | да | ID вопроса из `question` / `questions` |
|
||||
| answer | string / string[] | да | Ключ ответа ("A") или массив (["A", "C"]) |
|
||||
|
||||
```json
|
||||
@@ -553,32 +551,7 @@ Set-Cookie: новый `refreshToken` (ротация).
|
||||
|
||||
**Response 200:**
|
||||
|
||||
```json
|
||||
{
|
||||
"answered": {
|
||||
"questionId": "0192a8b0-9abc-7000-8000-000000000003",
|
||||
"isCorrect": true
|
||||
},
|
||||
"progress": {
|
||||
"answeredCount": 1,
|
||||
"totalCount": 10
|
||||
},
|
||||
"nextQuestion": {
|
||||
"id": "0192a8b0-9abc-7000-8000-000000000004",
|
||||
"orderNumber": 2,
|
||||
"type": "single_choice",
|
||||
"questionText": "Which HTML element is used for the largest heading?",
|
||||
"options": [
|
||||
{ "key": "A", "text": "<h6>" },
|
||||
{ "key": "B", "text": "<h1>" },
|
||||
{ "key": "C", "text": "<head>" },
|
||||
{ "key": "D", "text": "<header>" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`nextQuestion = null` если это был последний вопрос (клиент должен вызвать `/finish`).
|
||||
Возвращает полный **snapshot отвеченного вопроса** (формат из реализации). Структура может отличаться от минимальной «answered + progress + nextQuestion» — контракт определяется backend. Может включать детали ответа, правильный ответ, объяснение, прогресс и/или следующий вопрос.
|
||||
|
||||
**Ошибки:**
|
||||
|
||||
@@ -613,6 +586,8 @@ Set-Cookie: новый `refreshToken` (ротация).
|
||||
}
|
||||
```
|
||||
|
||||
`score` — количество правильных ответов (integer). `percentage` приходит с backend (или считается на фронте).
|
||||
|
||||
**Ошибки:**
|
||||
|
||||
| HTTP | Код | Когда |
|
||||
@@ -687,7 +662,7 @@ Set-Cookie: новый `refreshToken` (ротация).
|
||||
|
||||
### GET /tests/history
|
||||
|
||||
История тестов пользователя. Cursor-based пагинация, сортировка по дате (новые первые).
|
||||
История тестов пользователя. Offset-based пагинация, сортировка по дате (новые первые).
|
||||
|
||||
**Авторизация:** Bearer token
|
||||
|
||||
@@ -696,7 +671,7 @@ Set-Cookie: новый `refreshToken` (ротация).
|
||||
| Параметр | Тип | Обязательный | Описание |
|
||||
| ---------- | ----- | -------------- | --------- |
|
||||
| limit | integer | нет | default 10, max 50 |
|
||||
| cursor | uuid | нет | ID последнего теста предыдущей страницы |
|
||||
| offset | integer | нет | Смещение (default 0) |
|
||||
| stack | string | нет | Фильтр по стеку |
|
||||
| status | string | нет | Фильтр: completed / abandoned |
|
||||
|
||||
@@ -704,7 +679,7 @@ Set-Cookie: новый `refreshToken` (ротация).
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
"tests": [
|
||||
{
|
||||
"id": "0192a8b0-5678-7000-8000-000000000002",
|
||||
"stack": "html",
|
||||
@@ -717,10 +692,7 @@ Set-Cookie: новый `refreshToken` (ротация).
|
||||
"finishedAt": "2026-03-03T12:10:42.000Z"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"nextCursor": "0192a8b0-5678-7000-8000-000000000002",
|
||||
"hasMore": false
|
||||
}
|
||||
"total": 42
|
||||
}
|
||||
```
|
||||
|
||||
@@ -728,9 +700,9 @@ Set-Cookie: новый `refreshToken` (ротация).
|
||||
|
||||
## Admin
|
||||
|
||||
### GET /admin/questions/queue
|
||||
### GET /admin/questions/pending
|
||||
|
||||
QA очередь вопросов для модерации. Cursor-based пагинация.
|
||||
Список вопросов на модерацию (статус pending). Offset-based пагинация.
|
||||
|
||||
**Авторизация:** Bearer token (role: admin)
|
||||
|
||||
@@ -739,15 +711,14 @@ QA очередь вопросов для модерации. Cursor-based па
|
||||
| Параметр | Тип | Обязательный | Описание |
|
||||
| ---------- | ----- | -------------- | ---------- |
|
||||
| limit | integer | нет | default 20, max 50 |
|
||||
| cursor | uuid | нет | |
|
||||
| status | string | нет | pending / approved / rejected (default: pending) |
|
||||
| offset | integer | нет | Смещение (default 0) |
|
||||
| stack | string | нет | Фильтр по стеку |
|
||||
|
||||
**Response 200:**
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
"questions": [
|
||||
{
|
||||
"id": "0192a8b0-def0-7000-8000-000000000010",
|
||||
"stack": "html",
|
||||
@@ -769,10 +740,7 @@ QA очередь вопросов для модерации. Cursor-based па
|
||||
"reportsCount": 0
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"nextCursor": "0192a8b0-def0-7000-8000-000000000010",
|
||||
"hasMore": true
|
||||
}
|
||||
"total": 15
|
||||
}
|
||||
```
|
||||
|
||||
@@ -784,9 +752,37 @@ QA очередь вопросов для модерации. Cursor-based па
|
||||
|
||||
---
|
||||
|
||||
### POST /admin/questions/:id/approve
|
||||
|
||||
Одобрение вопроса (смена статуса на approved).
|
||||
|
||||
**Авторизация:** Bearer token (role: admin)
|
||||
|
||||
**Request:** пустое тело
|
||||
|
||||
**Response 200:** полный объект вопроса с обновлённым статусом.
|
||||
|
||||
Действие записывается в `audit_logs`.
|
||||
|
||||
---
|
||||
|
||||
### POST /admin/questions/:id/reject
|
||||
|
||||
Отклонение вопроса (смена статуса на rejected).
|
||||
|
||||
**Авторизация:** Bearer token (role: admin)
|
||||
|
||||
**Request:** пустое тело
|
||||
|
||||
**Response 200:** полный объект вопроса с обновлённым статусом.
|
||||
|
||||
Действие записывается в `audit_logs`.
|
||||
|
||||
---
|
||||
|
||||
### PATCH /admin/questions/:id
|
||||
|
||||
Одобрение, отклонение или редактирование вопроса.
|
||||
Редактирование контента вопроса **без смены статуса** (текст, варианты, правильный ответ, объяснение).
|
||||
|
||||
**Авторизация:** Bearer token (role: admin)
|
||||
|
||||
@@ -794,7 +790,6 @@ QA очередь вопросов для модерации. Cursor-based па
|
||||
|
||||
| Поле | Тип | Обязательное | Описание |
|
||||
| ------ | ----- | -------------- | ---------- |
|
||||
| status | string | нет | approved / rejected |
|
||||
| questionText | string | нет | Отредактированный текст |
|
||||
| options | array | нет | Отредактированные варианты |
|
||||
| correctAnswer | string | нет | Исправленный ответ |
|
||||
@@ -802,24 +797,11 @@ QA очередь вопросов для модерации. Cursor-based па
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "approved"
|
||||
}
|
||||
```
|
||||
|
||||
Или с редактированием:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "approved",
|
||||
"explanation": "The <meta charset> tag specifies the character encoding for the HTML document, typically UTF-8."
|
||||
}
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
|
||||
Полный объект вопроса (как в GET queue) с обновлёнными полями.
|
||||
|
||||
Действие записывается в `audit_logs`.
|
||||
**Response 200:** полный объект вопроса с обновлёнными полями.
|
||||
|
||||
**Ошибки:**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user