Files
samreshu_docs/api/contracts.md
Anton 99cd8ae727 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
2026-03-04 12:07:17 +03:00

846 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 день |