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:
Anton
2026-03-04 12:07:17 +03:00
commit 99cd8ae727
21 changed files with 3763 additions and 0 deletions

845
api/contracts.md Normal file
View 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 день |