docs: add full project documentation
- Architecture: overview, 7 ADR, tech stack - Principles: code-style, git-workflow, security - API contracts: auth, profile, tests, admin endpoints - Database schema: tables, relationships, indexes - LLM strategy: prompts, fallback, validation, Qwen 2.5 14B - Onboarding: setup, Docker, .env template - Progress: roadmap, changelog - Agents: context, backend instructions Made-with: Cursor
This commit is contained in:
845
api/contracts.md
Normal file
845
api/contracts.md
Normal file
@@ -0,0 +1,845 @@
|
||||
# API контракты
|
||||
|
||||
## Общие соглашения
|
||||
|
||||
- Базовый URL: `/api/v1`
|
||||
- Формат: JSON (`Content-Type: application/json`)
|
||||
- Аутентификация: Bearer token в заголовке `Authorization: Bearer <accessToken>`
|
||||
- Refresh token: httpOnly secure cookie `refreshToken` (устанавливается сервером при login/refresh)
|
||||
- Даты: ISO 8601, UTC (`2026-03-03T12:00:00.000Z`)
|
||||
- ID: UUID v7
|
||||
|
||||
### Формат ошибок
|
||||
|
||||
Все ошибки возвращаются в едином формате:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "ERROR_CODE",
|
||||
"message": "Human-readable description"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Общие коды ошибок
|
||||
|
||||
| HTTP | Код | Описание |
|
||||
| ------ | ----- | ---------- |
|
||||
| 400 | `BAD_REQUEST` | Некорректный запрос |
|
||||
| 401 | `UNAUTHORIZED` | Не авторизован или токен истёк |
|
||||
| 403 | `FORBIDDEN` | Нет прав |
|
||||
| 404 | `NOT_FOUND` | Ресурс не найден |
|
||||
| 409 | `CONFLICT` | Конфликт (дубликат) |
|
||||
| 422 | `VALIDATION_ERROR` | Невалидные данные |
|
||||
| 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` если это последняя страница.
|
||||
|
||||
---
|
||||
|
||||
## Auth
|
||||
|
||||
### POST /auth/register
|
||||
|
||||
Регистрация нового пользователя. Отправляет письмо с кодом подтверждения email.
|
||||
|
||||
**Авторизация:** не требуется
|
||||
|
||||
**Request:**
|
||||
|
||||
| Поле | Тип | Обязательное | Валидация |
|
||||
| ------ | ----- | -------------- | ----------- |
|
||||
| email | string | да | format: email, max 255 |
|
||||
| password | string | да | min 8, max 128 |
|
||||
| nickname | string | да | min 2, max 30, alphanumeric + underscore |
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "securePass123",
|
||||
"nickname": "john_doe"
|
||||
}
|
||||
```
|
||||
|
||||
**Response 201:**
|
||||
|
||||
```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..."
|
||||
}
|
||||
```
|
||||
|
||||
Set-Cookie: `refreshToken=<token>; HttpOnly; Secure; SameSite=Strict; Path=/api/v1/auth; Max-Age=604800`
|
||||
|
||||
**Ошибки:**
|
||||
|
||||
| HTTP | Код | Когда |
|
||||
| ------ | ----- | ------- |
|
||||
| 409 | `EMAIL_TAKEN` | Email уже зарегистрирован |
|
||||
| 422 | `VALIDATION_ERROR` | Невалидные данные |
|
||||
| 429 | `RATE_LIMIT_EXCEEDED` | Более 3 регистраций с IP за час |
|
||||
|
||||
---
|
||||
|
||||
### POST /auth/login
|
||||
|
||||
Аутентификация по email и паролю.
|
||||
|
||||
**Авторизация:** не требуется
|
||||
|
||||
**Request:**
|
||||
|
||||
| Поле | Тип | Обязательное |
|
||||
| ------ | ----- | -------------- |
|
||||
| email | string | да |
|
||||
| password | string | да |
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "securePass123"
|
||||
}
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"id": "0192a8b0-1234-7000-8000-000000000001",
|
||||
"email": "user@example.com",
|
||||
"nickname": "john_doe",
|
||||
"role": "free",
|
||||
"emailVerified": true
|
||||
},
|
||||
"accessToken": "eyJhbGciOiJIUzI1NiIs..."
|
||||
}
|
||||
```
|
||||
|
||||
Set-Cookie: `refreshToken=<token>; HttpOnly; Secure; SameSite=Strict; Path=/api/v1/auth; Max-Age=604800`
|
||||
|
||||
Создаётся запись в `sessions` с device info и IP.
|
||||
|
||||
**Ошибки:**
|
||||
|
||||
| HTTP | Код | Когда |
|
||||
| ------ | ----- | ------- |
|
||||
| 401 | `INVALID_CREDENTIALS` | Неверный email или пароль |
|
||||
| 403 | `ACCOUNT_LOCKED` | Прогрессивный lockout (brute force) |
|
||||
| 429 | `RATE_LIMIT_EXCEEDED` | Превышен лимит попыток входа |
|
||||
|
||||
---
|
||||
|
||||
### POST /auth/logout
|
||||
|
||||
Завершение текущей сессии. Удаляет запись из `sessions`, очищает refresh cookie.
|
||||
|
||||
**Авторизация:** Bearer token
|
||||
|
||||
**Request:** пустое тело
|
||||
|
||||
**Response 200:**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Logged out successfully"
|
||||
}
|
||||
```
|
||||
|
||||
Set-Cookie: `refreshToken=; HttpOnly; Secure; SameSite=Strict; Path=/api/v1/auth; Max-Age=0`
|
||||
|
||||
---
|
||||
|
||||
### POST /auth/refresh
|
||||
|
||||
Обновление access token по refresh token из cookie. Выполняет ротацию refresh token.
|
||||
|
||||
**Авторизация:** не требуется (refresh token в cookie)
|
||||
|
||||
**Request:** пустое тело (refresh token читается из cookie)
|
||||
|
||||
**Response 200:**
|
||||
|
||||
```json
|
||||
{
|
||||
"accessToken": "eyJhbGciOiJIUzI1NiIs..."
|
||||
}
|
||||
```
|
||||
|
||||
Set-Cookie: новый `refreshToken` (ротация).
|
||||
|
||||
**Ошибки:**
|
||||
|
||||
| HTTP | Код | Когда |
|
||||
| ------ | ----- | ------- |
|
||||
| 401 | `INVALID_REFRESH_TOKEN` | Токен невалидный или истёк |
|
||||
| 401 | `TOKEN_REUSE_DETECTED` | Повторное использование старого токена — все сессии пользователя инвалидируются |
|
||||
|
||||
---
|
||||
|
||||
### POST /auth/verify-email
|
||||
|
||||
Подтверждение email по коду из письма.
|
||||
|
||||
**Авторизация:** Bearer token
|
||||
|
||||
**Request:**
|
||||
|
||||
| Поле | Тип | Обязательное |
|
||||
| ------ | ----- | -------------- |
|
||||
| code | string | да |
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Email verified successfully"
|
||||
}
|
||||
```
|
||||
|
||||
**Ошибки:**
|
||||
|
||||
| HTTP | Код | Когда |
|
||||
| ------ | ----- | ------- |
|
||||
| 400 | `INVALID_CODE` | Неверный или истёкший код |
|
||||
| 409 | `ALREADY_VERIFIED` | Email уже подтверждён |
|
||||
|
||||
---
|
||||
|
||||
### POST /auth/forgot-password
|
||||
|
||||
Запрос на сброс пароля. Отправляет письмо с кодом/ссылкой.
|
||||
|
||||
**Авторизация:** не требуется
|
||||
|
||||
**Request:**
|
||||
|
||||
| Поле | Тип | Обязательное |
|
||||
| ------ | ----- | -------------- |
|
||||
| email | string | да |
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
|
||||
Всегда возвращает успех (даже если email не найден — чтобы не раскрывать наличие аккаунта):
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "If this email is registered, a reset link has been sent"
|
||||
}
|
||||
```
|
||||
|
||||
**Ошибки:**
|
||||
|
||||
| HTTP | Код | Когда |
|
||||
| ------ | ----- | ------- |
|
||||
| 429 | `RATE_LIMIT_EXCEEDED` | Более 3 запросов с IP за час |
|
||||
|
||||
---
|
||||
|
||||
### POST /auth/reset-password
|
||||
|
||||
Сброс пароля по токену из письма.
|
||||
|
||||
**Авторизация:** не требуется
|
||||
|
||||
**Request:**
|
||||
|
||||
| Поле | Тип | Обязательное |
|
||||
| ------ | ----- | ------------- |
|
||||
| token | string | да |
|
||||
| password | string | да |
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "reset-token-from-email",
|
||||
"password": "newSecurePass456"
|
||||
}
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Password reset successfully"
|
||||
}
|
||||
```
|
||||
|
||||
Все сессии пользователя инвалидируются после сброса пароля.
|
||||
|
||||
**Ошибки:**
|
||||
|
||||
| HTTP | Код | Когда |
|
||||
| ------ | ----- | ------- |
|
||||
| 400 | `INVALID_RESET_TOKEN` | Токен невалидный или истёк |
|
||||
| 422 | `VALIDATION_ERROR` | Пароль не соответствует требованиям |
|
||||
|
||||
---
|
||||
|
||||
## Profile
|
||||
|
||||
### GET /profile
|
||||
|
||||
Текущий профиль авторизованного пользователя.
|
||||
|
||||
**Авторизация:** Bearer token
|
||||
|
||||
**Response 200:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "0192a8b0-1234-7000-8000-000000000001",
|
||||
"email": "user@example.com",
|
||||
"nickname": "john_doe",
|
||||
"avatarUrl": null,
|
||||
"country": "Russia",
|
||||
"city": null,
|
||||
"selfLevel": null,
|
||||
"isPublic": true,
|
||||
"role": "free",
|
||||
"emailVerified": true,
|
||||
"plan": "free",
|
||||
"createdAt": "2026-03-03T12:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
Поле `plan` берётся из `subscriptions` через subscription middleware.
|
||||
|
||||
---
|
||||
|
||||
### PATCH /profile
|
||||
|
||||
Обновление профиля.
|
||||
|
||||
**Авторизация:** Bearer token
|
||||
|
||||
**Request** (все поля опциональны):
|
||||
|
||||
| Поле | Тип | Валидация |
|
||||
| ------ | ----- | ----------- |
|
||||
| nickname | string | min 2, max 30 |
|
||||
| country | string | max 100 |
|
||||
| city | string | max 100 |
|
||||
| selfLevel | string | enum: jun / mid / sen |
|
||||
| isPublic | boolean | |
|
||||
|
||||
```json
|
||||
{
|
||||
"nickname": "jane_doe",
|
||||
"country": "Russia",
|
||||
"selfLevel": "jun"
|
||||
}
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
|
||||
Полный объект профиля (как в GET /profile) с обновлёнными полями.
|
||||
|
||||
**Ошибки:**
|
||||
|
||||
| HTTP | Код | Когда |
|
||||
| ------ | ----- | ------- |
|
||||
| 409 | `NICKNAME_TAKEN` | Никнейм уже занят |
|
||||
| 422 | `VALIDATION_ERROR` | Невалидные данные |
|
||||
|
||||
---
|
||||
|
||||
### GET /profile/:username
|
||||
|
||||
Публичный профиль пользователя.
|
||||
|
||||
**Авторизация:** не требуется
|
||||
|
||||
**Response 200:**
|
||||
|
||||
```json
|
||||
{
|
||||
"nickname": "john_doe",
|
||||
"avatarUrl": null,
|
||||
"country": "Russia",
|
||||
"selfLevel": "jun",
|
||||
"createdAt": "2026-03-03T12:00:00.000Z",
|
||||
"stats": {
|
||||
"testsCompleted": 42,
|
||||
"averageScore": 78
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Не возвращает email, role, план — только публичная информация.
|
||||
|
||||
**Ошибки:**
|
||||
|
||||
| HTTP | Код | Когда |
|
||||
| ------ | ----- | ------- |
|
||||
| 404 | `USER_NOT_FOUND` | Пользователь не найден или профиль скрыт (`isPublic = false`) |
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
### POST /tests
|
||||
|
||||
Создание нового теста. Генерирует вопросы через LLM или берёт из банка.
|
||||
|
||||
**Авторизация:** Bearer token
|
||||
|
||||
**Request:**
|
||||
|
||||
| Поле | Тип | Обязательное | Валидация |
|
||||
| ------ | ----- | -------------- | ----------- |
|
||||
| stack | string | да | enum: html / css (MVP 0) |
|
||||
| level | string | да | enum: basic / beginner (MVP 0) |
|
||||
| questionCount | integer | да | enum: 10 / 20 |
|
||||
|
||||
```json
|
||||
{
|
||||
"stack": "html",
|
||||
"level": "basic",
|
||||
"questionCount": 10
|
||||
}
|
||||
```
|
||||
|
||||
**Response 201:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "0192a8b0-5678-7000-8000-000000000002",
|
||||
"stack": "html",
|
||||
"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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
При создании теста вопросы копируются в `test_questions` (снепшот). Первый вопрос возвращается сразу.
|
||||
|
||||
**Ошибки:**
|
||||
|
||||
| HTTP | Код | Когда |
|
||||
| ------ | ----- | ------- |
|
||||
| 403 | `DAILY_LIMIT_REACHED` | Free: 5 тестов в день |
|
||||
| 403 | `EMAIL_NOT_VERIFIED` | Email не подтверждён |
|
||||
| 422 | `VALIDATION_ERROR` | Невалидный стек/уровень |
|
||||
| 503 | `QUESTIONS_UNAVAILABLE` | LLM и банк вопросов недоступны |
|
||||
|
||||
---
|
||||
|
||||
### GET /tests/:id
|
||||
|
||||
Получение текущего состояния теста (для восстановления после перезагрузки страницы).
|
||||
|
||||
**Авторизация:** Bearer token (только свой тест)
|
||||
|
||||
**Response 200:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "0192a8b0-5678-7000-8000-000000000002",
|
||||
"stack": "html",
|
||||
"level": "basic",
|
||||
"questionCount": 10,
|
||||
"status": "in_progress",
|
||||
"currentQuestion": 3,
|
||||
"answeredCount": 2,
|
||||
"startedAt": "2026-03-03T12:05:00.000Z",
|
||||
"timeLimitSeconds": null,
|
||||
"question": {
|
||||
"id": "0192a8b0-9abc-7000-8000-000000000005",
|
||||
"orderNumber": 3,
|
||||
"type": "true_false",
|
||||
"questionText": "The <div> element is an inline element.",
|
||||
"options": [
|
||||
{ "key": "A", "text": "True" },
|
||||
{ "key": "B", "text": "False" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Не возвращает правильные ответы и объяснения — только текущий вопрос.
|
||||
|
||||
**Ошибки:**
|
||||
|
||||
| HTTP | Код | Когда |
|
||||
| ------ | ----- | ------- |
|
||||
| 403 | `FORBIDDEN` | Тест принадлежит другому пользователю |
|
||||
| 404 | `NOT_FOUND` | Тест не найден |
|
||||
|
||||
---
|
||||
|
||||
### POST /tests/:id/answer
|
||||
|
||||
Ответ на текущий вопрос. Возвращает следующий вопрос.
|
||||
|
||||
**Авторизация:** Bearer token (только свой тест)
|
||||
|
||||
**Request:**
|
||||
|
||||
| Поле | Тип | Обязательное | Описание |
|
||||
| ------ | ----- | -------------- | ---------- |
|
||||
| questionId | uuid | да | ID вопроса из `question` |
|
||||
| answer | string / string[] | да | Ключ ответа ("A") или массив (["A", "C"]) |
|
||||
|
||||
```json
|
||||
{
|
||||
"questionId": "0192a8b0-9abc-7000-8000-000000000003",
|
||||
"answer": "A"
|
||||
}
|
||||
```
|
||||
|
||||
**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`).
|
||||
|
||||
**Ошибки:**
|
||||
|
||||
| HTTP | Код | Когда |
|
||||
| ------ | ----- | ------- |
|
||||
| 400 | `QUESTION_ALREADY_ANSWERED` | Вопрос уже отвечен |
|
||||
| 400 | `WRONG_QUESTION` | questionId не соответствует текущему вопросу |
|
||||
| 400 | `TEST_ALREADY_FINISHED` | Тест завершён |
|
||||
| 422 | `VALIDATION_ERROR` | Невалидный ответ |
|
||||
|
||||
---
|
||||
|
||||
### POST /tests/:id/finish
|
||||
|
||||
Завершение теста. Подсчитывает результат, обновляет `user_stats`.
|
||||
|
||||
**Авторизация:** Bearer token (только свой тест)
|
||||
|
||||
**Request:** пустое тело
|
||||
|
||||
**Response 200:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "0192a8b0-5678-7000-8000-000000000002",
|
||||
"status": "completed",
|
||||
"score": 8,
|
||||
"totalQuestions": 10,
|
||||
"percentage": 80,
|
||||
"timeSpentSeconds": 342,
|
||||
"finishedAt": "2026-03-03T12:10:42.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Ошибки:**
|
||||
|
||||
| HTTP | Код | Когда |
|
||||
| ------ | ----- | ------- |
|
||||
| 400 | `TEST_ALREADY_FINISHED` | Тест уже завершён |
|
||||
| 400 | `NO_ANSWERS` | Ни один вопрос не отвечен |
|
||||
|
||||
---
|
||||
|
||||
### GET /tests/:id/results
|
||||
|
||||
Детальные результаты теста с разбором каждого вопроса.
|
||||
|
||||
**Авторизация:** Bearer token (только свой тест)
|
||||
|
||||
**Response 200:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "0192a8b0-5678-7000-8000-000000000002",
|
||||
"stack": "html",
|
||||
"level": "basic",
|
||||
"score": 8,
|
||||
"totalQuestions": 10,
|
||||
"percentage": 80,
|
||||
"timeSpentSeconds": 342,
|
||||
"startedAt": "2026-03-03T12:05:00.000Z",
|
||||
"finishedAt": "2026-03-03T12:10:42.000Z",
|
||||
"questions": [
|
||||
{
|
||||
"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" }
|
||||
],
|
||||
"userAnswer": "A",
|
||||
"correctAnswer": "A",
|
||||
"isCorrect": true,
|
||||
"explanation": "The <meta charset=\"UTF-8\"> tag specifies the character encoding for the HTML document."
|
||||
},
|
||||
{
|
||||
"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>" }
|
||||
],
|
||||
"userAnswer": "C",
|
||||
"correctAnswer": "B",
|
||||
"isCorrect": false,
|
||||
"explanation": "<h1> defines the largest heading. <head> is a container for metadata, not a heading element."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Ошибки:**
|
||||
|
||||
| HTTP | Код | Когда |
|
||||
| ------ | ----- | ------- |
|
||||
| 400 | `TEST_NOT_FINISHED` | Тест ещё не завершён |
|
||||
| 404 | `NOT_FOUND` | Тест не найден |
|
||||
|
||||
---
|
||||
|
||||
### GET /tests/history
|
||||
|
||||
История тестов пользователя. Cursor-based пагинация, сортировка по дате (новые первые).
|
||||
|
||||
**Авторизация:** Bearer token
|
||||
|
||||
**Query параметры:**
|
||||
|
||||
| Параметр | Тип | Обязательный | Описание |
|
||||
| ---------- | ----- | -------------- | --------- |
|
||||
| limit | integer | нет | default 10, max 50 |
|
||||
| cursor | uuid | нет | ID последнего теста предыдущей страницы |
|
||||
| stack | string | нет | Фильтр по стеку |
|
||||
| status | string | нет | Фильтр: completed / abandoned |
|
||||
|
||||
**Response 200:**
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "0192a8b0-5678-7000-8000-000000000002",
|
||||
"stack": "html",
|
||||
"level": "basic",
|
||||
"questionCount": 10,
|
||||
"score": 8,
|
||||
"percentage": 80,
|
||||
"status": "completed",
|
||||
"startedAt": "2026-03-03T12:05:00.000Z",
|
||||
"finishedAt": "2026-03-03T12:10:42.000Z"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"nextCursor": "0192a8b0-5678-7000-8000-000000000002",
|
||||
"hasMore": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Admin
|
||||
|
||||
### GET /admin/questions/queue
|
||||
|
||||
QA очередь вопросов для модерации. Cursor-based пагинация.
|
||||
|
||||
**Авторизация:** Bearer token (role: admin)
|
||||
|
||||
**Query параметры:**
|
||||
|
||||
| Параметр | Тип | Обязательный | Описание |
|
||||
| ---------- | ----- | -------------- | ---------- |
|
||||
| limit | integer | нет | default 20, max 50 |
|
||||
| cursor | uuid | нет | |
|
||||
| status | string | нет | pending / approved / rejected (default: pending) |
|
||||
| stack | string | нет | Фильтр по стеку |
|
||||
|
||||
**Response 200:**
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "0192a8b0-def0-7000-8000-000000000010",
|
||||
"stack": "html",
|
||||
"level": "basic",
|
||||
"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" }
|
||||
],
|
||||
"correctAnswer": "A",
|
||||
"explanation": "The meta charset tag specifies the character encoding.",
|
||||
"source": "llm_generated",
|
||||
"status": "pending",
|
||||
"usageCount": 0,
|
||||
"createdAt": "2026-03-03T11:00:00.000Z",
|
||||
"reportsCount": 0
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"nextCursor": "0192a8b0-def0-7000-8000-000000000010",
|
||||
"hasMore": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Ошибки:**
|
||||
|
||||
| HTTP | Код | Когда |
|
||||
| ------ | ----- | ------- |
|
||||
| 403 | `FORBIDDEN` | Пользователь не admin |
|
||||
|
||||
---
|
||||
|
||||
### PATCH /admin/questions/:id
|
||||
|
||||
Одобрение, отклонение или редактирование вопроса.
|
||||
|
||||
**Авторизация:** Bearer token (role: admin)
|
||||
|
||||
**Request:**
|
||||
|
||||
| Поле | Тип | Обязательное | Описание |
|
||||
| ------ | ----- | -------------- | ---------- |
|
||||
| status | string | нет | approved / rejected |
|
||||
| questionText | string | нет | Отредактированный текст |
|
||||
| options | array | нет | Отредактированные варианты |
|
||||
| correctAnswer | string | нет | Исправленный ответ |
|
||||
| explanation | string | нет | Исправленное объяснение |
|
||||
|
||||
```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`.
|
||||
|
||||
**Ошибки:**
|
||||
|
||||
| HTTP | Код | Когда |
|
||||
| ------ | ----- | ------- |
|
||||
| 403 | `FORBIDDEN` | Пользователь не admin |
|
||||
| 404 | `NOT_FOUND` | Вопрос не найден |
|
||||
| 422 | `VALIDATION_ERROR` | Невалидные данные |
|
||||
|
||||
---
|
||||
|
||||
## Rate limits по endpoint
|
||||
|
||||
Подробности в [security.md](../principles/security.md). Краткая сводка:
|
||||
|
||||
| Группа | Лимит | Окно |
|
||||
| -------- | ------- | ------ |
|
||||
| `/auth/login` | Прогрессивный lockout | 15 мин / 1 час / 24 часа |
|
||||
| `/auth/register` | 3 | 1 час |
|
||||
| `/auth/forgot-password` | 3 | 1 час |
|
||||
| Общий (авторизованный) | 100 | 1 мин |
|
||||
| Общий (гость) | 30 | 1 мин |
|
||||
| Тесты (Free) | 5 тестов | 1 день |
|
||||
Reference in New Issue
Block a user