Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

0
docs/api/.gitkeep Normal file
View File

184
docs/api/README.md Normal file
View File

@@ -0,0 +1,184 @@
# Документация API
VidConf REST + WebSocket API: аутентификация, профиль, динамические конференции, чат, администрирование, health check. Конференции создаются динамически — предустановленных комнат и бронирований нет (см. [ADR-001](../architecture/adr/001-dynamic-conferences-pivot.md)).
## Интерактивная документация
FastAPI автоматически генерирует интерактивные документы:
- **Swagger UI:** `http://localhost:8000/docs`
- **ReDoc:** `http://localhost:8000/redoc`
- **OpenAPI JSON:** `http://localhost:8000/openapi.json`
## Аутентификация & авторизация
**[Полная документация: docs/api/auth.md](auth.md)**
| Эндпоинт | Метод | Описание |
|----------|-------|---------|
| `/api/v1/auth/registration-options` | GET | Публичные опции регистрации (выбор команды) |
| `/api/v1/auth/register` | POST | Регистрация нового пользователя |
| `/api/v1/auth/verify-email` | POST | Подтверждение email по токену |
| `/api/v1/auth/token` | POST | OAuth2 вход (email + пароль) → access + refresh токены |
| `/api/v1/auth/refresh` | POST | Ротация refresh-токена → новый access-токен |
| `/api/v1/auth/logout` | POST | Отзыв refresh-токена |
## Профиль пользователя
**[Полная документация: docs/api/users.md](users.md)**
| Эндпоинт | Метод | Описание |
|----------|-------|---------|
| `/api/v1/users/me` | GET | Профиль текущего пользователя |
| `/api/v1/users/me` | PATCH | Обновить ФИО и команду |
| `/api/v1/users/me/avatar` | POST | Загрузить аватар (JPEG/PNG/WebP, ≤2 МБ) |
| `/api/v1/users/me/avatar` | DELETE | Удалить аватар |
| `/api/v1/users` | GET | Поиск пользователей для пикера участников |
## Справочник команд
**[Полная документация: docs/api/teams.md](teams.md)**
| Эндпоинт | Метод | Описание |
|----------|-------|---------|
| `/api/v1/teams` | GET | Список всех команд (для выбора в профиле) |
**Для CRUD операций над командами см. [Администраторский API](./admin.md#команды).**
## LiveKit интеграция
| Эндпоинт | Метод | Описание |
|----------|-------|---------|
| `/api/v1/livekit/webhook` | POST | Приёмник webhook-событий от LiveKit |
## GET /api/health
Проверка доступности backend и зависимостей.
**Request:**
```bash
curl http://localhost:8000/api/health
```
**Response (200 OK):**
```json
{
"status": "ok",
"db": true,
"redis": true
}
```
---
## Динамические конференции
### Управление конференциями
**[Полная документация: docs/api/conferences.md](conferences.md)**
| Эндпоинт | Метод | Описание |
|----------|-------|---------|
| `/api/v1/conferences` | POST | Создать конференцию (мгновенную без scheduled_at или плановую) |
| `/api/v1/conferences/my` | GET | Мои конференции: закреплённые + предстоящие разовые |
| `/api/v1/conferences/calendar` | GET | Развёртка вхождений (occurrences) в диапазоне дат (≤62 дней) |
| `/api/v1/conferences/resolve` | GET | Найти конференцию по номеру или ссылке (публичный, rate limit 10/мин) |
| `/api/v1/conferences/{id}/join` | POST | Вход зарегистрированного пользователя (с пароль-опцион) |
| `/api/v1/conferences/{id}/guest-join` | POST | Вход гостем (публичный, rate limit, display_name обязателен) |
| `/api/v1/conferences/{id}` | PATCH | Изменить конференцию (владелец/администратор) |
| `/api/v1/conferences/{id}` | DELETE | Удалить конференцию (запрещено для активной) |
**Ключевые особенности** (в отличие от прежней модели бронирования переговорных комнат, см. ADR-001):
- Конференция имеет 9-значный номер (вместо room + booking)
- Вход по номеру или постоянной ссылке (slug), без привязки к комнате
- Гости представляются (display_name + email опционально) и участвуют в пайплайне
- Закреплённые конференции могут повторяться (weekly/biweekly/monthly/every_n_days)
- Нет конкуренции за ресурсы → EXCLUDE constraint не используется, конфликты — на уровне бизнес-логики
---
## Администраторский API
**[Полная документация: docs/api/admin.md](admin.md)**
| Эндпоинт | Метод | Описание |
|----------|-------|---------|
| `/api/v1/admin/conferences` | GET | Список всех конференций с фильтрацией и пагинацией (только админ) |
| `/api/v1/admin/conferences/{id}` | PATCH | Изменить конференцию (только админ) |
| `/api/v1/admin/conferences/{id}` | DELETE | Удалить конференцию (только админ, запрещено для активной) |
| `/api/v1/admin/conferences/{id}/invitations` | POST | Поставить рассылку .ics-приглашений (202, асинхронно) |
| `/api/v1/admin/users` | GET | Список пользователей с пагинацией (только админ) |
| `/api/v1/admin/users/{id}` | PATCH | Изменить роль/блокировку/команду пользователя (только админ, запрет самоизменения роли/блокировки) |
| `/api/v1/admin/teams` | GET | Список всех команд (только админ) |
| `/api/v1/admin/teams` | POST | Создать команду (только админ, 409 при дубле названия) |
| `/api/v1/admin/teams/{id}` | PATCH | Переименовать команду (только админ) |
| `/api/v1/admin/teams/{id}` | DELETE | Удалить команду (только админ, у пользователей обнулится team_id) |
| `/api/v1/admin/settings` | GET | Текущие настройки инстанса (только админ) |
| `/api/v1/admin/settings` | PUT | Обновить настройки инстанса (только админ; AI-уровень/таймзона/домен регистрации валидируются) |
Все эндпоинты требуют JWT токен администратора (403 иначе).
---
## Чат конференции
### Обмен сообщениями в реальном времени
**[Полная документация: docs/api/chat.md](chat.md)**
| Эндпоинт | Протокол | Описание |
|----------|----------|---------|
| `/api/v1/conferences/{id}/chat` | WebSocket | Текстовый чат (auth по LiveKit-токену, история, broadcast через Redis pub/sub) |
**Особенности:**
- Аутентификация на уровне протокола WS (первое сообщение `{"type":"auth","token":<LiveKit-токен>}`)
- История: последние 50 сообщений открытой сессии конференции
- Участники: зарегистрированные пользователи + гости (с пометкой `is_guest`)
- Тоггл `chat.enabled` из настроек инстанса в БД, проверяется на каждом подключении
- Отправитель видит своё сообщение через pub/sub (echo)
**Close-коды:** 4401 (невалидный auth), 4403 (чужая комната), 4404 (чат выключен / конференция не найдена / завершена).
**Frontend:** JoinOut содержит `chat_enabled` для отображения/скрытия UI панели без рестарта.
---
## Планируемый API
- Прямой доступ к транскрипту сеанса через API (`GET /api/sessions/{id}/transcript`) — сейчас фразы доступны только внутри пайплайна и итогового саммари, отдельного read-эндпоинта нет.
Транскрибация, суммаризация и рассылка саммари/приглашений уже реализованы
асинхронно через Celery-воркеры (статус — `pipeline_status` в
`conference_sessions`, см. [workers/README.md](../../workers/README.md)).
---
## Соглашения
**Все timestamps в UTC:**
```json
"created_at": "2026-07-15T14:30:00Z"
```
**Авторизация:** JWT Bearer token в заголовке `Authorization` (для защищённых эндпоинтов)
**Публичные эндпоинты (без auth):**
- `GET /api/v1/conferences/resolve` — поиск по номеру/ссылке (rate limit)
- `POST /api/v1/conferences/{id}/guest-join` — вход гостем (rate limit)
**CORS:** Frontend настроен в dev-прокси (vite.config.ts: `/api``localhost:8000`)
**Плагины:** Конфигурация AI сервисов через `config/plugins.yaml`. См. [docs/plugins/contracts.md](../plugins/contracts.md).
---
## Тестирование
```bash
# Health check
curl http://localhost:8000/api/health
# Swagger docs
open http://localhost:8000/docs
```
Полная спецификация — в Swagger UI/ReDoc (см. выше) и в файлах этого каталога.

578
docs/api/admin.md Normal file
View File

@@ -0,0 +1,578 @@
# Администраторский API
Эндпоинты управления инстансом VidConf — конференции, пользователи, команды, настройки. **Все эндпоинты требуют роль `admin` (403 иначе).**
## Авторизация
Все запросы должны включать JWT Bearer токен администратора в заголовке `Authorization`:
```bash
Authorization: Bearer <access_token>
```
Попытка обращения без роли `admin`**403 Forbidden** с `detail="Forbidden"`.
## Эндпоинты
### Конференции
#### GET /api/v1/admin/conferences
**Список всех конференций инстанса с фильтрацией и пагинацией.**
**Query параметры:**
- `status` (опционально) — фильтр по статусу: `scheduled`, `active`, `ended`
- `q` (опционально) — текстовый поиск по названию/номеру/slug
- `limit` (опционально, default=50, max=200) — строк на странице
- `offset` (опционально, default=0) — смещение (пагинация)
**Response (200 OK):**
```json
{
"items": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"number": "123456789",
"slug": "abc123456",
"title": "Встреча Q3",
"status": "scheduled",
"is_pinned": true,
"is_closed": false,
"scheduled_at": "2026-07-20T14:00:00Z",
"duration_minutes": 60,
"recurrence": { "type": "weekly", "weekdays": [0, 2, 4] },
"summary_recipients": "all",
"created_at": "2026-07-16T12:00:00Z",
"owner_name": "Иван Иванов",
"owner_email": "ivan@example.com"
}
],
"total": 42
}
```
**Ключевые поля:**
- `owner_name` / `owner_email` — имя и email владельца конференции (null если владельца нет)
- `summary_recipients` — текущий режим рассылки (null = дефолт инстанса)
---
#### PATCH /api/v1/admin/conferences/{conference_id}
**Изменить конференцию (реюз `ConferenceService.update`, те же правила валидации).**
**Path параметры:**
- `conference_id` (UUID)
**Request body:** (все поля опциональны)
```json
{
"title": "Новое название",
"scheduled_at": "2026-07-25T15:00:00Z",
"duration_minutes": 90,
"is_pinned": true,
"recurrence": null,
"is_closed": false,
"password": null,
"summary_recipients": "owner"
}
```
**Response (200 OK):** обновленная конференция (как GET /conferences)
**Коды ошибок:**
- `404` — conference_not_found
- `422` — invalid_conference_state (попытка добавить recurrence к неживой конференции и т.п.)
**Примечание:** админ проходит проверку владения как "владелец ИЛИ админ" — можно менять чужие конференции.
---
#### DELETE /api/v1/admin/conferences/{conference_id}
**Удалить конференцию (409 для активной).**
**Path параметры:**
- `conference_id` (UUID)
**Response (204 No Content)**
**Коды ошибок:**
- `404` — conference_not_found
- `409` — conference_active (активную конференцию удалить нельзя)
---
#### POST /api/v1/admin/conferences/{conference_id}/invitations
**Поставить в очередь ручную рассылку .ics-приглашений (202 Accepted, отправка идёт в Celery).**
**Path параметры:**
- `conference_id` (UUID)
**Request body:** (опционально)
```json
{
"emails": ["alice@example.com", "bob@example.com"]
}
```
**Response (202 Accepted)** — без тела (задача поставлена в очередь)
**Логика:**
- Если `emails` опущена/null — получатели по умолчанию: владелец конференции + для закреплённых — участники прошлых сеансов
- Если `emails` передан — явный список адресов
- Каждое приглашение добавляется в `email_deliveries` с `kind='invitation'` (журнал без unique, дубликаты допускаются)
**Коды ошибок:**
- `404` — conference_not_found
---
### Пользователи
#### GET /api/v1/admin/users
**Список всех пользователей инстанса с пагинацией.**
**Query параметры:**
- `q` (опционально) — текстовый поиск по email/имени
- `limit` (опционально, default=50, max=200)
- `offset` (опционально, default=0)
**Response (200 OK):**
```json
{
"items": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "admin@example.com",
"name_user": "Администратор",
"role": "admin",
"email_verified": true,
"is_blocked": false,
"team_id": null,
"created_at": "2026-07-16T12:00:00Z"
}
],
"total": 15
}
```
---
#### POST /api/v1/admin/users
**Создать пользователя от имени администратора.**
Email сразу считается подтверждённым (письмо на верификацию не отправляется); роль по умолчанию — `user`. Дубль email → **409** `email_already_registered`. Несуществующая команда → **404** `team_not_found`.
**Request body:**
```json
{
"name_user": "Иван Петров",
"email": "ivan@example.com",
"password": "securePassword123",
"team_id": "550e8400-e29b-41d4-a716-446655440001"
}
```
**Поля:**
- `name_user` (str, 1..255) — отображаемое имя (обязательно)
- `email` (EmailStr) — адрес электронной почты (обязательно, уникален)
- `password` (str, ≥8) — пароль пользователя (политика как при регистрации)
- `team_id` (UUID или null) — опциональная привязка к команде (не обязана быть выбрана, в отличие от публичной регистрации)
**Response (201 Created):**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440002",
"email": "ivan@example.com",
"name_user": "Иван Петров",
"role": "user",
"is_blocked": false,
"email_verified": true,
"created_at": "2026-07-21T10:30:00Z",
"team_id": "550e8400-e29b-41d4-a716-446655440001",
"avatar_url": null,
"team_name": "Backend"
}
```
**Коды ошибок:**
- `409` — email_already_registered (email уже зарегистрирован)
- `404` — team_not_found (передан несуществующий `team_id`)
- `422` — Unprocessable Entity (пароль короче 8 символов, валидация email и т.д.)
**Примечание:** В отличие от самостоятельной регистрации (`POST /auth/register`), пользователь не проходит верификацию email — роль назначается как `user`, а `email_verified` сразу `true`.
---
#### GET /api/v1/admin/users/{user_id}
**Карточка профиля пользователя — те же данные, что в пользовательском профиле + роль и статус блокировки.**
**Path параметры:**
- `user_id` (UUID)
**Response (200 OK):**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"name_user": "Иван Петров",
"role": "user",
"is_blocked": false,
"team_id": "550e8400-e29b-41d4-a716-446655440001",
"team_name": "Backend",
"avatar_path": "avatars/550e8400-e29b-41d4-a716-446655440000.jpg",
"avatar_url": "/media/avatars/550e8400-e29b-41d4-a716-446655440000.jpg",
"created_at": "2026-07-18T12:00:00Z"
}
```
**Поля:**
- `avatar_path` — путь к файлу аватара (относительно `MEDIA_ROOT`), `null` если нет
- `avatar_url` — готовый URL для отображения (`/media/avatars/...`)
- `team_name` — имя команды (null если не привязан)
**Коды ошибок:**
- `404` — user_not_found
---
#### PATCH /api/v1/admin/users/{user_id}
**Изменить роль, статус блокировки, ФИО, команду пользователя.**
**Path параметры:**
- `user_id` (UUID)
**Request body:** (все поля опциональны)
```json
{
"role": "admin",
"is_blocked": true,
"name_user": "Новое имя",
"team_id": "550e8400-e29b-41d4-a716-446655440000"
}
```
**Response (200 OK):** обновленный пользователь (как GET /admin/users/{id})
**Коды ошибок:**
- `404` — user_not_found (пользователь) или team_not_found (несуществующий `team_id`)
- `409` — cannot_modify_self (попытка изменить свою `role`/`is_blocked`; своё `name_user` и `team_id` менять можно)
**Семантика:**
- `is_blocked=true` — пользователь получит 401 на любом защищённом эндпоинте (проверка в `api/deps.py::_user_from_token`), немедленно, без ожидания истечения access-токена
- `role` может быть `"admin"` или `"user"`
- `name_user` — новое ФИО пользователя
- `team_id` — явное `null` снимает привязку к команде; отсутствие поля в теле запроса команду не трогает (различается через `model_fields_set`); запрет самоизменения (409) на `team_id` **не распространяется**
---
#### POST /api/v1/admin/users/{user_id}/avatar
**Загрузить аватар пользователю.**
**Path параметры:**
- `user_id` (UUID)
**Request body:** `multipart/form-data`
- `file` — файл изображения (обязателен)
**Требования:**
- Формат: JPEG, PNG или WebP (проверяется по magic bytes)
- Размер: максимум 2 МБ
**Response (200 OK):** обновленный пользователь (как GET /admin/users/{id}, с новым `avatar_url`)
**Коды ошибок:**
- `404` — user_not_found
- `413` — avatar_too_large (файл больше 2 МБ)
- `415` — avatar_invalid_type (не JPEG/PNG/WebP)
**Примечание:** Старый аватар автоматически удаляется при загрузке нового.
---
### Команды
#### GET /api/v1/admin/teams
**Список всех команд, отсортированный по названию.**
**Response (200 OK):**
```json
{
"items": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Backend",
"created_at": "2026-07-18T12:00:00Z"
}
],
"total": 1
}
```
---
#### POST /api/v1/admin/teams
**Создать команду.**
**Request body:**
```json
{ "name": "Backend" }
```
`name` обрезается от пробелов (1..255 символов после обрезки).
**Response (201 Created):** созданная команда
**Коды ошибок:**
- `409` — team_name_taken (команда с таким названием уже существует)
---
#### PATCH /api/v1/admin/teams/{team_id}
**Переименовать команду.**
**Path параметры:**
- `team_id` (UUID)
**Request body:**
```json
{ "name": "Backend & Platform" }
```
**Response (200 OK):** обновлённая команда
**Коды ошибок:**
- `404` — team_not_found
- `409` — team_name_taken
---
#### DELETE /api/v1/admin/teams/{team_id}
**Удалить команду.**
**Path параметры:**
- `team_id` (UUID)
**Response (204 No Content)**
У пользователей, состоявших в удалённой команде, `team_id` автоматически обнуляется (`ON DELETE SET NULL`) — сами пользователи не удаляются.
**Коды ошибок:**
- `404` — team_not_found
---
### Настройки инстанса
#### GET /api/v1/admin/settings
**Текущие эффективные настройки инстанса (собрань из БД + дефолты).**
**Response (200 OK):**
```json
{
"chat_enabled": false,
"transcription_enabled": true,
"ai_level": "min",
"ai_levels": [
{"level": "min", "available": true, "reason": null},
{"level": "medium", "available": false, "reason": "Недостаточно памяти"},
{"level": "max", "available": false, "reason": "Нет GPU"}
],
"summary_recipients": "all",
"display_timezone": "Europe/Moscow",
"registration_team_choice": false,
"registration_email_domain_enabled": false,
"registration_email_domain": null,
"transcription_queue_served": true
}
```
**Поля:**
- `chat_enabled` — включен ли чат в конференциях
- `transcription_enabled` — включены ли транскрибация и суммаризация (единый переключатель)
- `ai_level` — текущий уровень AI (`min`, `medium`, `max`)
- `ai_levels` — доступность всех уровней с причинами (например, `medium` может быть недоступен при недостатке памяти, `max` требует GPU)
- `summary_recipients` — режим рассылки саммри по умолчанию (`all` или `owner`)
- `display_timezone` — таймзона отображения времени в письмах и .ics (IANA, например `Europe/Moscow`)
- `registration_team_choice` — разрешён ли выбор команды на публичной форме регистрации (дефолт `false`); см. `GET /api/v1/auth/registration-options`
- `registration_email_domain_enabled` — включена ли верификация регистрирующихся по домену email (дефолт `false`)
- `registration_email_domain` — эталонный домен email (нормализован: без ведущего `@`, в нижнем регистре); `null`, пока верификация не настроена
- `transcription_queue_served``true`, если хотя бы один Celery-воркер `transcriber` активно обслуживает очередь транскрибации; `false` = предупреждение в админке (см. ниже)
---
#### PUT /api/v1/admin/settings
**Частично обновить настройки инстанса (недоступный уровень AI/неверная таймзона → 400).**
**Request body:** (все поля опциональны, PATCH-семантика)
```json
{
"chat_enabled": true,
"transcription_enabled": false,
"ai_level": "min",
"summary_recipients": "owner",
"display_timezone": "Europe/London",
"registration_team_choice": true,
"registration_email_domain_enabled": true,
"registration_email_domain": "example.com"
}
```
**Response (200 OK):** обновленные настройки (как GET /settings)
**Коды ошибок:**
- `400` — invalid AI level (попытка установить недоступный уровень, например `medium` без нужного железа), неверная таймзона (не IANA) или некорректная настройка верификации домена email (включение без домена либо домен не проходит валидацию паттерном)
**Логика:**
- `chat_enabled`: пишет `enabled` в хранилище настроек (`instance_settings.chat`)
- `transcription_enabled`: пишет `enabled` сразу в обе секции (`transcriber`, `summarizer`) — единый переключатель; **guard:** если установлено `true`, но `transcription_queue_served=false` в ответе `GET /settings`, админка отображает предупреждение «Очередь транскрибации не обслуживается ни одним Celery-воркером» (воркеры не запущены, обслуживают другую очередь или недоступны)
- `ai_level`: проверяется `detect_ai_levels()` на доступность перед сохранением
- `display_timezone`: валидируется как IANA (через `ZoneInfo`)
- `summary_recipients`: может быть `"all"` или `"owner"` (дефолт для новых конференций)
- `registration_team_choice`: включает/выключает выбор команды на публичной форме регистрации (`instance_settings.registration_team_choice`); влияет на `GET /api/v1/auth/registration-options` и допустимость `team_id` в `POST /api/v1/auth/register`
- `registration_email_domain_enabled` / `registration_email_domain`: включают верификацию регистрирующихся по домену email (`instance_settings.registration_email_domain`); включение (`enabled=true`) при пустом/отсутствующем домене → `400`; домен нормализуется (`strip`, без ведущей `@`, нижний регистр) и валидируется простым паттерном доменного имени, иначе `400`; влияет на `GET /api/v1/auth/registration-options` (`email_domain`) и `POST /api/v1/auth/register` (`400 invalid_email_domain` при несовпадении домена)
**Примечание:** Изменение настроек **не требует рестарта** ни backend'а, ни воркеров Celery (они читают конфиг на старте каждой задачи).
### Предупреждение об очереди транскрибации
При включённой транскрибации (`transcription_enabled=true`) админка проверяет
доступность очереди через поле `transcription_queue_served` в ответе
`GET /api/v1/admin/settings`:
- **`transcription_queue_served=true`:** хотя бы один Celery-воркер `transcriber`
активен и обслуживает очередь — новые конференции будут обработаны
- **`transcription_queue_served=false`:** ни один воркер не подключён к очереди —
**все** сеансы конференций по завершении зависнут в статусе `transcribing`
(задачи накопятся в Redis, никто их не возьмёт)
**Решение:** запустить хотя бы один воркер транскрибации:
```bash
docker compose up -d transcriber
# или
cd workers && celery -A transcriber worker -q
```
После запуска воркера поле `transcription_queue_served` обновится на `true`
(проверяется при каждом запросе к `/api/v1/admin/settings`).
---
## Примеры запросов
### Получить все конференции с фильтром по статусу
```bash
curl -X GET \
'http://localhost:8000/api/v1/admin/conferences?status=scheduled&limit=20&offset=0' \
-H "Authorization: Bearer $TOKEN"
```
### Изменить режим рассылки саммри конференции
```bash
curl -X PATCH \
'http://localhost:8000/api/v1/admin/conferences/550e8400-e29b-41d4-a716-446655440000' \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"summary_recipients": "owner"}'
```
### Поставить ручную рассылку приглашений
```bash
curl -X POST \
'http://localhost:8000/api/v1/admin/conferences/550e8400-e29b-41d4-a716-446655440000/invitations' \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"emails": ["alice@example.com", "bob@example.com"]}'
```
### Создать пользователя
```bash
curl -X POST \
'http://localhost:8000/api/v1/admin/users' \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name_user": "Иван Петров",
"email": "ivan@example.com",
"password": "securePassword123",
"team_id": "550e8400-e29b-41d4-a716-446655440001"
}'
```
### Заблокировать пользователя
```bash
curl -X PATCH \
'http://localhost:8000/api/v1/admin/users/550e8400-e29b-41d4-a716-446655440000' \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"is_blocked": true}'
```
### Назначить пользователю команду
```bash
curl -X PATCH \
'http://localhost:8000/api/v1/admin/users/550e8400-e29b-41d4-a716-446655440000' \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"team_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"}'
```
### Изменить настройки инстанса
```bash
curl -X PUT \
'http://localhost:8000/api/v1/admin/settings' \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"chat_enabled": true,
"ai_level": "min",
"display_timezone": "Europe/London",
"summary_recipients": "all"
}'
```
---
## Безопасность и ограничения
- **Только для админов:** все эндпоинты блокируют 403 для роли `user`
- **Самоизменение роли/блокировки запрещено:** PATCH собственных `role`/`is_blocked` → 409 (`cannot_modify_self`); свою команду (`team_id`) администратор менять может
- **Секреты SMTP не видны:** почтовые реквизиты хранятся только в `.env`, в API не попадают
- **Блокировка немедленна:** `is_blocked=true` действует на любом защищённом эндпоинте без ожидания истечения токена
- **Рассылка в очереди:** отправка писем идёт асинхронно через Celery (202, не 200), ошибки логируются в воркере
---
## Примечания
1. **Изменение расписания конференции** (PATCH `scheduled_at`/`duration_minutes`/`recurrence`/`title`) инкрементирует `ics_sequence` и ставит в очередь `send_invitations` — календарные клиенты получат обновление по UID события.
2. **Переопределение рассылки саммри** (`summary_recipients` в конференции) сильнее дефолта инстанса — если в конференции установлено, оно используется; иначе берётся настройка из `instance_settings.summary_recipients`.
3. **AI-уровни:** три уровня качества `min`/`medium`/`max` (Qwen3.5-4B/9B/35B-A3B, faster-whisper small/medium/large-v3). Доступность уровня определяется детектом железа (HW_CPUS/HW_RAM_MB/HW_GPU_NAME/HW_VRAM_MB в `.env`). Недоступный уровень → 400 Bad Request с объяснением. ADR-004: [docs/architecture/adr/004-ai-tier-matrix.md](../architecture/adr/004-ai-tier-matrix.md)
---
## Ссылки
- [Конфигурация инстанса](../architecture/README.md#Компоненты) — общее описание системы
- [Схема БД](../db/schema.md) — таблицы `instance_settings`, `email_deliveries`
- [API конференций](./conferences.md) — пользовательский API (без админа)

513
docs/api/auth.md Normal file
View File

@@ -0,0 +1,513 @@
# Аутентификация и авторизация
Эндпоинты для регистрации, верификации email, входа и управления JWT-токенами.
## Быстрый старт
```bash
# 1. Регистрация
curl -X POST http://localhost:8000/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","name_user":"John","password":"securepass123"}'
# 2. Проверить письмо, скопировать токен из логов (ConsoleEmailBackend)
# 3. Подтвердить email
curl -X POST http://localhost:8000/api/v1/auth/verify-email \
-H "Content-Type: application/json" \
-d '{"token":"ТОКЕНЗ_ПИСЬМА"}'
# 4. Войти и получить access-токен
curl -X POST http://localhost:8000/api/v1/auth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d 'username=user@example.com&password=securepass123' \
-i # -i чтобы увидеть Set-Cookie с refresh-токеном
# 5. Использовать access-токен
curl http://localhost:8000/api/v1/users/me \
-H "Authorization: Bearer ТВОЙ_ACCESS_TOKEN"
# 6. Обновить access-токен (refresh-токен в cookie автоматический)
curl -X POST http://localhost:8000/api/v1/auth/refresh \
-b "refresh_token=ТВОЙ_REFRESH_TOKEN" \
-i
# 7. Выход (отозвать refresh-токен)
curl -X POST http://localhost:8000/api/v1/auth/logout \
-b "refresh_token=ТВОЙ_REFRESH_TOKEN" \
-i
```
## Эндпоинты
### GET /api/v1/auth/registration-options
Публичные опции карточки регистрации (без авторизации) — доступен ли выбор команды и список команд для селектора.
**Ответ (200 OK):**
```json
{
"team_choice_enabled": true,
"teams": [
{"id": "550e8400-e29b-41d4-a716-446655440000", "name": "Alpha"},
{"id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "name": "Backend"}
],
"email_domain": "example.com"
}
```
**Поля:**
- `team_choice_enabled` — значение настройки инстанса `registration_team_choice` (админка, `PUT /api/v1/admin/settings`); дефолт `false`
- `teams` — список команд, отсортированный по названию; **пустой массив**, если `team_choice_enabled=false` (справочник команд не раскрывается, пока выбор выключен)
- `email_domain` — эталонный домен email при включённой настройке инстанса `registration_email_domain_enabled`, иначе `null`
---
### POST /api/v1/auth/register
Зарегистрировать нового пользователя и отправить письмо для подтверждения email.
**Тело запроса:**
```json
{
"email": "user@example.com",
"name_user": "Иван Петров",
"password": "securepass123",
"team_id": "550e8400-e29b-41d4-a716-446655440000"
}
```
**Поля:**
- `email` (строка, email): Адрес электронной почты; уникален в системе
- `name_user` (строка, 1-255 символов): Отображаемое имя пользователя
- `password` (строка, минимум 8 символов): Пароль (хэшируется с argon2)
- `team_id` (UUID, опционально): Команда пользователя; допустим только когда `registration_team_choice` включена в настройках инстанса (см. `GET /registration-options`) и `team_id` ссылается на существующую команду
**Ответ (201 Created):**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"name_user": "Иван Петров",
"role": "user"
}
```
**Ошибки:**
- `409 Conflict` (`email_already_registered`) — пользователь с таким email уже зарегистрирован
- `400 Bad Request` (`invalid_team_selection`) — передан `team_id`, а выбор команды выключен в настройках, либо команда с таким id не существует (единая ошибка для обоих случаев — публичный эндпоинт не перебирает id команд)
- `400 Bad Request` (`invalid_email_domain`) — включена верификация домена email (`registration_email_domain_enabled`), а домен в `email` (часть после `@`, без учёта регистра) не совпадает с эталонным `registration_email_domain`; проверяется до создания пользователя
- `422 Unprocessable Entity` — валидация (пароль < 8 символов, некорректный email и т.д.)
**Побочный эффект:**
- На указанный email отправляется письмо с ссылкой на верификацию (консоль в dev)
---
### POST /api/v1/auth/verify-email
Подтвердить email пользователя по одноразовому токену из письма.
**Тело запроса:**
```json
{
"token": "ДЛИННЫЙ_ТОКЕНЗ_ПИСЬМА"
}
```
**Поля:**
- `token` (строка): Одноразовый токен подтверждения (256 бит, URL-safe base64)
**Ответ (204 No Content):** — пустой ответ при успехе
**Ошибки:**
- `400 Bad Request` (`invalid_or_expired_token`) — токен не найден, уже использован или истёк (TTL по `.env`: `EMAIL_VERIFICATION_TTL_HOURS`, по умолчанию 24 часа)
**Побочный эффект:**
- Устанавливает `email_verified = true` для пользователя
- Отмечает токен как использованный (`used_at = now`)
---
### POST /api/v1/auth/token
OAuth2 password flow: аутентификация по email и паролю. Выдаёт пару токенов (access + refresh).
**Тело запроса:** (form-data или application/x-www-form-urlencoded)
```
username=user@example.com&password=securepass123
```
**Параметры:**
- `username` (строка): Email пользователя (OAuth2 соглашение использует `username`)
- `password` (строка): Пароль в открытом виде
**Ответ (200 OK):**
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer"
}
```
**Поля:**
- `access_token` (строка): JWT access-токен; используется в заголовке `Authorization: Bearer <token>`
- `token_type` (строка): Всегда `"bearer"`
**Заголовок ответа:**
```
Set-Cookie: refresh_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...; HttpOnly; Secure; SameSite=Strict; Max-Age=2592000
```
**Ошибки:**
- `401 Unauthorized` (`invalid_credentials`) — email не найден или пароль неверный
- `403 Forbidden` (`email_not_verified`) — email ещё не подтвержден (требуется `/verify-email`)
**Описание токенов:**
| Параметр | Тип | TTL | Место | Ротация |
|----------|-----|-----|-------|---------|
| access_token | JWT | 15 минут | Тело ответа | Не ротируется; истекает автоматически |
| refresh_token | JWT | 30 дней | httpOnly cookie | Ротируется при каждом использовании `/refresh` |
**Содержимое access-токена (payload):**
```json
{
"sub": "550e8400-e29b-41d4-a716-446655440000",
"role": "user",
"type": "access",
"exp": 1234567890,
"iat": 1234567200
}
```
**Содержимое refresh-токена (payload):**
```json
{
"sub": "550e8400-e29b-41d4-a716-446655440000",
"jti": "UNIQUE_ID",
"type": "refresh",
"exp": 1234567890,
"iat": 1234567200
}
```
---
### POST /api/v1/auth/refresh
Ротировать refresh-токен и выдать новый access-токен.
**Параметры:**
- Cookie: `refresh_token=ТВОЙ_REFRESH_TOKEN` (httpOnly, отправляется браузером автоматически)
**Ответ (200 OK):**
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer"
}
```
**Заголовок ответа:**
```
Set-Cookie: refresh_token=NEW_JWT; HttpOnly; Secure; SameSite=Strict; Max-Age=2592000
```
**Ошибки:**
- `401 Unauthorized` (`missing_refresh_token`) — cookie `refresh_token` не передана
- `401 Unauthorized` (`invalid_refresh_token`) — токен невалиден, просрочен, отозван или уже был использован (reuse)
**Механика ротации:**
1. Backend декодирует refresh-токен и проверяет его `jti` в Redis (`refresh:{jti}``user_id`)
2. Если `jti` есть в Redis → токен валиден, пользователь существует
3. Если `jti` отсутствует → reuse или отозван → `401`
4. Старый `jti` немедленно удаляется из Redis
5. Выдаётся новый refresh-токен с новым `jti`, добавляется в Redis
6. Повторное использование уже потраченного refresh-токена автоматически блокируется
---
### POST /api/v1/auth/logout
Отозвать refresh-токен (удалить его из Redis) и погасить httpOnly cookie.
**Параметры:**
- Cookie: `refresh_token=ТВОЙ_REFRESH_TOKEN` (опционально; если отсутствует, просто удалится cookie)
**Ответ (204 No Content):** — пустой ответ при успехе
**Побочный эффект:**
- Удаляет `jti` из Redis → последующие попытки использовать этот refresh-токен дадут `401`
- Удаляет cookie `refresh_token` (устанавливает в пустое значение)
---
### GET /api/v1/users/me
Получить профиль текущего аутентифицированного пользователя.
**Параметры:**
- Заголовок: `Authorization: Bearer <access_token>` (обязателен)
**Ответ (200 OK):**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"name_user": "Иван Петров",
"role": "user"
}
```
**Ошибки:**
- `401 Unauthorized` (`not_authenticated`) — access-токен отсутствует, невалиден или истёк
---
## RBAC (Role-Based Access Control)
### Роли
- `admin` — администратор (полный доступ, требуется require_admin)
- `user` — обычный пользователь (доступ к основным функциям)
- `guest` — не аутентифицированный пользователь (отсутствие JWT)
### Зависимости FastAPI для авторизации
**`get_current_user` — требуется аутентификация**
```python
@router.get("/profile")
async def profile(user: Annotated[User, Depends(get_current_user)]) -> UserOut:
"""Доступно только аутентифицированным пользователям."""
return user
```
- Возвращает объект User
- 401 если токен отсутствует, невалиден или истёк
**`get_current_user_optional` — опциональная аутентификация (guest = None)**
```python
@router.get("/public")
async def public_data(user: Annotated[User | None, Depends(get_current_user_optional)]) -> dict:
"""Доступно всем; guest может прочитать, но не будет знать о себе."""
if user is None:
return {"message": "hello guest"}
return {"message": f"hello {user.name_user}"}
```
- Возвращает User или None
- Никогда не выбрасывает 401; отсутствие JWT → `None`
- **Важно:** Guest в системе — это отсутствие JWT, а не отдельное enum-значение в БД
**`require_admin` — требуется роль admin**
```python
@router.delete("/users/{id}")
async def delete_user(id: uuid.UUID, admin: Annotated[User, Depends(require_admin)]) -> None:
"""Доступно только администраторам."""
# ...
```
- 401 если не аутентифицирован
- 403 если роль не `admin`
---
## Архитектурные решения
### Refresh-токены в httpOnly cookies
Refresh-токены хранятся в httpOnly cookie (недоступно из JavaScript), чтобы защитить их от XSS. Браузер отправляет cookie автоматически на каждый запрос к `/api/v1/auth/refresh`.
**Почему httpOnly + Secure + SameSite=Strict:**
- **httpOnly** — недоступно из JavaScript (защита от XSS)
- **Secure** — передаётся только по HTTPS (защита от MITM)
- **SameSite=Strict** — не отправляется при кросс-сайтовых запросах (защита от CSRF)
### Server-side refresh-token validation
Refresh-токены проверяются через Redis-хранилище (`refresh:{jti} → user_id`):
- **Отзыв:** удаление из Redis на logout или reuse
- **Ротация:** старый jti удаляется немедленно после использования → reuse → 401
- **TTL:** Redis-ключ имеет TTL, равный сроку жизни токена
Это позволяет:
1. Отозвать токены без пересоздания ключей подписи
2. Обнаружить replay-атаки (повторное использование старого токена)
3. Контролировать количество активных сессий per user (если реализовать)
### Сравнение с альтернативами
| Подход | Плюсы | Минусы | Используется |
|--------|-------|--------|-------------|
| httpOnly cookie | Защита от XSS | Требует SameSite для защиты CSRF | ✅ Refresh-токен |
| Bearer token в теле | Явный контроль | Уязвимо для XSS | ✅ Access-токен (одноразовый) |
| Opaque tokens (session ID) | Компактно | Требует БД на каждый запрос | ❌ Не используется |
---
## Примеры использования
### JavaScript / TypeScript (frontend)
```typescript
// Регистрация
async function register(email: string, name: string, password: string) {
const res = await fetch('/api/v1/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, name_user: name, password }),
})
if (!res.ok) throw new Error(`Error: ${res.status}`)
return await res.json()
}
// Подтверждение email
async function verifyEmail(token: string) {
const res = await fetch('/api/v1/auth/verify-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
})
if (!res.ok) throw new Error(`Error: ${res.status}`)
}
// Вход
async function login(email: string, password: string) {
const formData = new URLSearchParams()
formData.append('username', email)
formData.append('password', password)
const res = await fetch('/api/v1/auth/token', {
method: 'POST',
body: formData,
credentials: 'include', // ВАЖНО: отправлять cookies
})
if (!res.ok) throw new Error(`Error: ${res.status}`)
const data = await res.json()
localStorage.setItem('access_token', data.access_token) // Храним access-токен
// refresh-токен в cookie автоматический (httpOnly, браузер управляет)
return data
}
// Использование access-токена
async function getProfile() {
const token = localStorage.getItem('access_token')
const res = await fetch('/api/v1/users/me', {
headers: { 'Authorization': `Bearer ${token}` },
})
if (!res.ok) throw new Error(`Error: ${res.status}`)
return await res.json()
}
// Обновление access-токена (refresh-токен в cookie отправляется автоматически)
async function refreshToken() {
const res = await fetch('/api/v1/auth/refresh', {
method: 'POST',
credentials: 'include', // ВАЖНО: отправлять cookies
})
if (!res.ok) throw new Error(`Error: ${res.status}`)
const data = await res.json()
localStorage.setItem('access_token', data.access_token)
return data
}
// Выход
async function logout() {
const res = await fetch('/api/v1/auth/logout', {
method: 'POST',
credentials: 'include', // ВАЖНО: отправлять cookies
})
localStorage.removeItem('access_token') // Удаляем access-токен
// refresh-токен будет удалён серверноvim (cookie)
}
```
### curl примеры
```bash
# Полный цикл регистрации и входа
# 1. Регистрация
curl -X POST http://localhost:8000/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","name_user":"Alice","password":"securepass123"}'
# Ответ: {"id":"...", "email":"alice@example.com", "name_user":"Alice", "role":"user"}
# 2. Проверить логи консоли (ConsoleEmailBackend выведет токен подтверждения)
# Из вывода скопировать token, например: "...token=abc123..."
# 3. Подтвердить email
curl -X POST http://localhost:8000/api/v1/auth/verify-email \
-H "Content-Type: application/json" \
-d '{"token":"abc123"}'
# Ответ: (204 No Content)
# 4. Вход
curl -X POST http://localhost:8000/api/v1/auth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d 'username=alice@example.com&password=securepass123' \
-i
# Ответ:
# HTTP/1.1 200 OK
# Set-Cookie: refresh_token=eyJ...; HttpOnly; Secure; SameSite=Strict; ...
# {"access_token":"eyJ...", "token_type":"bearer"}
# 5. Сохранить access-токен из ответа, использовать его
export ACCESS_TOKEN="eyJ..."
# 6. Получить профиль
curl http://localhost:8000/api/v1/users/me \
-H "Authorization: Bearer $ACCESS_TOKEN"
# Ответ: {"id":"...", "email":"alice@example.com", "name_user":"Alice", "role":"user"}
# 7. Обновить access-токен (refresh_token в cookie отправляется автоматически)
curl -X POST http://localhost:8000/api/v1/auth/refresh \
-b "refresh_token=eyJ..." \
-i
# Ответ:
# HTTP/1.1 200 OK
# Set-Cookie: refresh_token=NEW_TOKEN; HttpOnly; Secure; SameSite=Strict; ...
# {"access_token":"NEW_ACCESS_TOKEN", "token_type":"bearer"}
# 8. Выход
curl -X POST http://localhost:8000/api/v1/auth/logout \
-b "refresh_token=eyJ..." \
-i
# Ответ: HTTP/1.1 204 No Content (cookie удалена)
# Попытка использовать refresh-токен после logout → 401
curl -X POST http://localhost:8000/api/v1/auth/refresh \
-b "refresh_token=eyJ..." \
-i
# Ответ: HTTP/1.1 401 Unauthorized {"detail":"invalid_refresh_token"}
```
---
## Константы и конфигурация
Все TTL и сроки хранения настраиваются через `.env`:
```env
# Access-токен (JWT)
ACCESS_TOKEN_TTL_MINUTES=15
# Refresh-токен (JWT + Redis)
REFRESH_TOKEN_TTL_DAYS=30
# Токен подтверждения email
EMAIL_VERIFICATION_TTL_HOURS=24
# JWT secret (используется для подписи всех токенов)
JWT_SECRET=your-secret-key-here
# Алгоритм хэширования паролей (argon2)
ARGON2_TIME_COST=2
ARGON2_MEMORY_COST=19 # 2^19 КБ = 512 МБ
ARGON2_PARALLELISM=1
```
---
## Связанные файлы
- **Реализация:** `backend/api/auth.py`, `backend/api/users.py`, `backend/api/deps.py`
- **Бизнес-логика:** `backend/services/auth.py`
- **Безопасность:** `backend/core/security.py` (token creation/verification, password hashing)
- **Модели:** `backend/models/user.py`, `backend/models/email_verification.py`
- **Схемы:** `backend/schemas/auth.py`
- **Тесты:** `backend/tests/test_auth.py`, `backend/tests/test_rbac.py`

272
docs/api/chat.md Normal file
View File

@@ -0,0 +1,272 @@
# API Чата
Текстовый чат в реальном времени для участников конференции. Реализован через WebSocket с аутентификацией по LiveKit-токену и broadcast через Redis pub/sub.
## WS-эндпоинт
```
WS /api/v1/conferences/{conference_id}/chat
```
Подключение требует явной аутентификации на уровне протокола (первое сообщение).
## Жизненный цикл соединения
### 1. Accept и ожидание auth-сообщения (таймаут 10 с)
Сервер принимает WebSocket-соединение и ждёт первого (и только первого) сообщения типа `auth`:
```json
{
"type": "auth",
"token": "<LiveKit access-token>"
}
```
**Внимание:** токен не передаётся в query-параметре и не логируется nginx'ом (требование безопасности — токены не должны попадать в логи доступа).
### 2. Проверка допуска
Сервер выполняет последовательность проверок (порядок важен для единообразной обработки ошибок):
1. **Тоггл чата:** флаг `chat.enabled` из настроек инстанса в БД (`instance_settings`). Проверяется **на каждом подключении**, без рестарта backend.
2. **LiveKit-токен:** верификация подписи и целостности (`livekit.api.TokenVerifier`)
3. **Идентичность:**
- Зарегистрированный пользователь: `identity` = строка UUID пользователя; grant `video.room` должен совпадать с `conference.slug`
- Гость: `identity` = `guest:{guest_access_id}` (UUID гостевой записи); grant `video.room` должен совпадать с `conference.slug`
4. **Конференция:** существует и статус ≠ `ended`
### 3. Отправка истории
При успешной аутентификации сервер отправляет последние **50 сообщений** открытой сессии конференции:
```json
{
"type": "history",
"messages": [
{
"id": 1,
"author_id": "550e8400-e29b-41d4-a716-446655440000",
"author_name": "Иван Петров",
"is_guest": false,
"text": "Привет, все слышат?",
"created_at": "2026-07-18T14:30:00Z"
},
...
]
}
```
**Порядок:** Подписка на Redis pub/sub канал происходит **перед** отправкой истории. Это гарантирует, что сообщения, пришедшие от других клиентов в окне между SELECT истории и subscribe, не будут потеряны. На стыке возможен дубликат (одно сообщение и в history, и в первом pub/sub); дедуплицирование по `id`.
### 4. Двусторонний обмен
Клиент отправляет сообщения:
```json
{
"type": "message",
"text": "Мое сообщение"
}
```
Сервер пишет сообщение в БД, затем публикует его в Redis pub/sub. **Отправитель получает своё сообщение обратно через pub/sub** (echo). Порядок доставки единый для всех подписчиков канала.
Полученное сообщение (для всех участников, в т.ч. отправителя):
```json
{
"type": "message",
"message": {
"id": 2,
"author_id": "550e8400-e29b-41d4-a716-446655440001",
"author_name": "Анна Смирнова",
"is_guest": true,
"text": "Отлично, видно хорошо!",
"created_at": "2026-07-18T14:30:15Z"
}
}
```
### 5. Обработка ошибок протокола
Если клиент отправит невалидное JSON или нарушит протокол (невалидный `type`, отсутствующие поля), сервер отправляет:
```json
{
"type": "error",
"code": "invalid_message"
}
```
Соединение остаётся открытым; клиент может отправить следующее сообщение. Полный разрыв соединения происходит только по close-кодам ниже.
## Close-коды
Сервер закрывает WebSocket соединение со следующими кодами:
| Код | Причина | Детали |
|-----|---------|--------|
| **4401** | Невалидный auth-токен | Таймаут ожидания auth-сообщения, невалидный JSON, невалидная подпись LiveKit-токена, отсутствие identity/room в token claims или невалидный UUID. Детали причины не раскрываются клиенту. |
| **4403** | Неправильная комната | Токен валиден, но выдан не для этой конференции (grant `video.room``conference.slug`). |
| **4404** | Чат недоступен | Чат выключен в настройках инстанса (`chat.enabled = false`), или конференция не найдена, или конференция завершена (`status = ended`). Единый код для всех случаев — чтобы по коду закрытия нельзя было перебором отличить существующую конференцию от несуществующей. |
**Штатное закрытие:** когда клиент сам закрывает соединение — это не ошибка, обработчик корректно завершается.
## Защита от фантомных сессий
Если клиент пытается отправить сообщение уже после завершения конференции (`room_finished` от LiveKit):
1. Сервер перечитывает статус конференции свежим SELECT (не полагаясь на закэшированный объект)
2. Если статус = `ended`, отправляет close 4404; сообщение **не записывается** в БД
3. Если статус ≠ `ended` и открытой сессии нет, автоматически создаёт её
Это гарантирует, что "фантомные" сообщения (отправленные после `room_finished`, когда сеанс уже завершается) не создаются.
## Схема сообщения чата
### ChatMessageOut (выход)
```typescript
{
id: number, // BIGINT Identity(always=True), уникален в пределах инстанса
author_id: string | null, // UUID пользователя ИЛИ UUID гостевой записи; null не возможно (см. schema.md)
author_name: string, // Снапшот отображаемого имени из LiveKit-токена на момент отправки (макс 255 символов)
is_guest: boolean, // true если guest_access_id заполнен
text: string, // Текст сообщения (1..2000 символов)
created_at: string // UTC ISO-8601 с суффиксом Z
}
```
**Особенности:**
- `author_name` — снапшот, не ссылка на текущее имя пользователя (переживает переименование и удаление гостевой записи)
- `author_id` никогда не NULL (проверка на уровне БД: CHECK `user_id IS NOT NULL OR guest_access_id IS NOT NULL`)
## Конфигурация
### Настройка чата в инстансе
Флаг `chat.enabled` хранится в таблице `instance_settings` (key = `"chat"`, value = JSONB):
```json
{
"enabled": true
}
```
**Доступ клиенту:** флаг приходит в ответе `join` / `guest-join` (`JoinOut.chat_enabled`), по нему фронт показывает/скрывает UI панели чата.
**Изменение:** через админ-панель (`PATCH /api/v1/admin/settings`); без рестарта backend.
### Бутстрап из config/plugins.yaml
При первом запуске backend загружает `config/plugins.yaml` и создаёт запись в `instance_settings` (однократный идемпотентный импорт):
```yaml
chat:
enabled: true
```
Если `instance_settings['chat']` уже существует, бутстрап не перезаписывает.
## Примеры использования
### JavaScript/TypeScript (frontend)
```typescript
// Подключение
const ws = new WebSocket('ws://localhost:8000/api/v1/conferences/550e8400-e29b-41d4-a716-446655440000/chat')
// Отправка auth-токена (должен быть получен с backend при join)
ws.onopen = () => {
ws.send(JSON.stringify({
type: 'auth',
token: livekit_token
}))
}
// Получение истории и новых сообщений
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
if (msg.type === 'history') {
// Отрисовать историю из msg.messages
} else if (msg.type === 'message') {
// Добавить новое сообщение msg.message
} else if (msg.type === 'error') {
console.error('Chat error:', msg.code)
}
}
// Отправка сообщения
function sendMessage(text: string) {
ws.send(JSON.stringify({
type: 'message',
text: text
}))
}
// Закрытие
ws.onclose = (event) => {
if (event.code === 4403) {
console.error('Wrong room')
} else if (event.code === 4404) {
console.error('Chat unavailable or conference ended')
}
}
```
### curl (тестирование)
WebSocket из curl поддерживается ограниченно; рекомендуется использовать утилиту `wscat`:
```bash
npm install -g wscat
# Подключение и отправка auth
wscat -c ws://localhost:8000/api/v1/conferences/550e8400-e29b-41d4-a716-446655440000/chat
# Затем вручную:
{"type":"auth","token":"eyJ..."}
{"type":"message","text":"Hello"}
```
## Интеграция с остальной системой
### Redis pub/sub
Канал: `chat:{conference_id}` (UUID конференции).
**Publisher:** `services/chat.py:ChatService.persist_and_publish()` после INSERT в БД.
**Subscribers:** все подключённые WebSocket-клиенты конференции (несколько инстансов backend в балансировке).
### Таблица chat_messages
См. [docs/db/schema.md](../db/schema.md#chat_messages).
Строка: `(id, session_id, user_id, guest_access_id, author_name, text, created_at)`.
### Тоггл в JoinOut
При входе в конференцию (`POST /api/v1/conferences/{id}/join` или `/guest-join`) ответ включает:
```json
{
"livekit_url": "...",
"token": "...",
"room_name": "...",
"conference_id": "550e8400-e29b-41d4-a716-446655440000",
"chat_enabled": true
}
```
Фронт проверяет `chat_enabled` перед рендерингом UI панели чата.
## Ссылки
- [Database Schema — chat_messages](../db/schema.md#chat_messages)
- [Instance Settings](../db/schema.md#instance_settings)
- [Backend Code — api/chat.py](../../backend/api/chat.py)
- [Backend Code — services/chat.py](../../backend/services/chat.py)
- [Frontend Component — useChat.ts](../../frontend/src/hooks/useChat.ts)
- [Frontend Component — ChatPanel.tsx](../../frontend/src/components/room/ChatPanel.tsx)

540
docs/api/conferences.md Normal file
View File

@@ -0,0 +1,540 @@
# API конференций
Полная справка по эндпоинтам динамических конференций (см. ADR-001). Конференции создаются мгновенно или планируются; вместо предустановленных комнат и бронирований.
## Обзор
**Конференции** — это постоянные сущности (номер, ссылка, владелец). Каждый запуск создаёт сеанс (`ConferenceSession`) — единицу AI-пайплайна.
- **Номер:** 9 десятичных цифр, уникален, генерируется с retry
- **Ссылка:** `slug = secrets.token_urlsafe(8)` (11 base64url-символов), URL: `/j/{slug}`
- **Доступ:** По номеру или ссылке (публичные эндпоинты без auth, rate limit 10/мин)
- **Гости:** Представляются при входе (имя обязательно, email факультативен для саммари)
## Эндпоинты
### POST /api/v1/conferences
**Создать конференцию.**
Без `scheduled_at` → мгновенная (создатель входит сразу); с `scheduled_at` → плановая.
**Требует auth:** JWT Bearer token
**Request body:**
```json
{
"title": "Встреча Q3",
"scheduled_at": "2026-07-20T14:00:00Z",
"duration_minutes": 60,
"is_pinned": true,
"recurrence": {
"type": "weekly",
"weekdays": [0, 2, 4],
"time_local": "14:00",
"timezone": "Europe/Moscow",
"anchor_date": "2026-07-20",
"duration_minutes": 60
},
"is_closed": false,
"password": null,
"summary_recipients": null
}
```
**Поля:**
- `title` (str, опционально, ≤255 символов) — название конференции
- `scheduled_at` (ISO 8601 UTC, опционально) — время запуска; если пусто → мгновенная (status=active)
- `duration_minutes` (int, опционально, >0) — ожидаемая длительность (не обязывает)
- `is_pinned` (bool, опционально, default=false) — закреплённая ли (видна в "Моих конференциях")
- `recurrence` (RecurrenceRule, опционально) — только если is_pinned=true; см. ниже
- `is_closed` (bool, опционально, default=false) — требуется пароль для входа
- `password` (str, опционально, ≥4 символа) — обязателен если is_closed=true
- `summary_recipients` (str, опционально: `'all'`, `'owner'`, или null) — переопределение рассылки саммари конкретной конференции; null = использовать дефолт инстанса
- `participants` (array, опционально) — список приглашённых; каждый элемент: `{"user_id": "uuid" или null, "email": "string" или null}`. Организатор добавляется автоматически (не требуется в массиве, но если передан — дедуплицируется). Внешние email'ы приглашаются отдельно
**RecurrenceRule (JSONB в БД):**
```json
{
"type": "weekly|biweekly|monthly|every_n_days",
"weekdays": [0, 1, 2, 3, 4, 5, 6],
"day_of_month": null,
"interval_days": null,
"anchor_date": "2026-07-20",
"time_local": "HH:MM",
"timezone": "IANA (e.g. Europe/Moscow)",
"duration_minutes": 60
}
```
- **weekly:** `weekdays` обязателен (0=пн, 6=вс), список непустой
- **biweekly:** `weekdays` обязателен, повтор через 2 недели от anchor_date
- **monthly:** `day_of_month` обязателен (1..31; 31 → последний день короткого месяца)
- **every_n_days:** `interval_days` обязателен (≥1)
**Response (201 Created):**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"number": "123456789",
"slug": "abc123456",
"title": "Встреча Q3",
"status": "active|scheduled|ended",
"is_pinned": true,
"is_closed": false,
"scheduled_at": "2026-07-20T14:00:00Z",
"duration_minutes": 60,
"recurrence": { /* RecurrenceRule */ },
"summary_recipients": null,
"next_occurrence": "2026-07-22T14:00:00Z",
"created_at": "2026-07-16T12:00:00Z",
"join": {
"livekit_url": "wss://livekit.example.com",
"token": "eyJh...",
"room_name": "abc123456",
"conference_id": "550e8400-e29b-41d4-a716-446655440000"
}
}
```
Мгновенная конференция: `status=active`, `join` заполнено. Плановая: `status=scheduled`, `join=null`.
**Коды ошибок:**
- `422``closed_conference_requires_password` (is_closed но нет password)
- `422``recurrence_requires_pinned` (recurrence но is_pinned=false)
- `422``scheduled_at_in_the_past` (дата в прошлом)
---
### GET /api/v1/conferences/my
**Мои конференции: закреплённые + предстоящие разовые владельца ИЛИ приглашённого.**
С 2026-07-20 (решение поверх ADR-003) в выдачу попадает конференция, если
текущий пользователь:
- владелец (`owner_id == user.id`), **ИЛИ**
- приглашён по `user_id` (строка `conference_invitees` с `user_id == user.id`), **ИЛИ**
- приглашён по email (строка `conference_invitees` с `lower(email) == lower(email пользователя)`
— внешнее приглашение на адрес, под которым человек впоследствии зарегистрировался).
Критерии показа не меняются (закреплённая — безусловно; разовая — `status=scheduled`
и `scheduled_at` в будущем), меняется только круг «чья» конференция. Для
приглашённого `is_owner=false`, `organizer_name` — имя фактического владельца;
`participants` не заполняется (как и для владельца — список не раздувает состав).
**Требует auth:** JWT Bearer token
**Query parameters:** нет
**Response (200 OK):**
```json
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"number": "123456789",
"slug": "abc123456",
"title": "Встреча Q3",
"status": "scheduled",
"is_pinned": true,
"is_closed": false,
"scheduled_at": "2026-07-20T14:00:00Z",
"duration_minutes": 60,
"recurrence": { /* RecurrenceRule */ },
"summary_recipients": null,
"next_occurrence": "2026-07-22T14:00:00Z",
"created_at": "2026-07-16T12:00:00Z",
"join": null
}
]
```
---
### GET /api/v1/conferences/calendar
**Развёртка вхождений (occurrences) конференций в диапазоне дат — владельца ИЛИ приглашённого.**
Тот же принцип видимости, что у `GET /conferences/my` (см. выше, решение от
2026-07-20): вхождения строятся по конференциям, где пользователь владелец
или приглашён (по `user_id` или по `lower(email)`).
**Требует auth:** JWT Bearer token
**Query parameters:**
- `from` (ISO 8601 UTC) — начало диапазона
- `to` (ISO 8601 UTC) — конец диапазона
- Максимальная ширина: 62 дня; иначе 422
**Response (200 OK):**
```json
[
{
"conference_id": "550e8400-e29b-41d4-a716-446655440000",
"title": "Встреча Q3",
"starts_at": "2026-07-20T14:00:00Z",
"ends_at": "2026-07-20T15:00:00Z",
"number": "123456789",
"slug": "abc123456",
"is_pinned": true,
"is_closed": false
}
]
```
---
### GET /api/v1/conferences/resolve
**Найти конференцию по номеру или ссылке — публичный эндпоинт без auth.**
**Rate limit:** 10 запросов в минуту на IP
**Query parameters:**
- `q` (str, ≥1 символ) — номер (9 цифр) или slug
**Response (200 OK):**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "Встреча Q3",
"status": "active|scheduled|ended",
"is_closed": false,
"requires_password": false
}
```
**Коды ошибок:**
- `404` — не найдена (живая, мёртвая или несуществующая — единообразный ответ для безопасности)
- `429` — exceeded rate limit
---
### POST /api/v1/conferences/{conference_id}/join
**Вход зарегистрированного пользователя в конференцию.**
**Требует auth:** JWT Bearer token
**Path parameters:**
- `conference_id` (UUID)
**Request body:**
```json
{
"password": "optional_password"
}
```
**Response (200 OK):**
```json
{
"livekit_url": "wss://livekit.example.com",
"token": "eyJh...",
"room_name": "abc123456",
"conference_id": "550e8400-e29b-41d4-a716-446655440000"
}
```
**Коды ошибок:**
- `404` — conference_not_found
- `403` — password_required (закрытая конференция без пароля в теле запроса)
- `403` — invalid_password
- `410` — conference_ended (статус=ended)
---
### POST /api/v1/conferences/{conference_id}/guest-join
**Вход гостем: представиться (имя обязательно) — публичный, без auth.**
**Rate limit:** 10 запросов в минуту на IP
**Path parameters:**
- `conference_id` (UUID)
**Request body:**
```json
{
"display_name": "Иван Иванов",
"email": "ivan@example.com",
"password": "optional_password"
}
```
**Поля:**
- `display_name` (str, 1..255) — обязателен
- `email` (EmailStr, опционально) — для рассылки саммари
- `password` (str, опционально) — для закрытых конференций
**Response (200 OK):**
```json
{
"livekit_url": "wss://livekit.example.com",
"token": "eyJh...",
"room_name": "abc123456",
"conference_id": "550e8400-e29b-41d4-a716-446655440000"
}
```
**Коды ошибок:**
- `404` — conference_not_found
- `403` — password_required
- `403` — invalid_password
- `410` — conference_ended
- `429` — exceeded rate limit
- `422` — validation_error (display_name пуст, email некорректен)
---
### PATCH /api/v1/conferences/{conference_id}
**Изменить конференцию — только владелец или администратор.**
**Требует auth:** JWT Bearer token
**Path parameters:**
- `conference_id` (UUID)
**Request body:** все поля опциональны (как ConferenceCreateIn, но PATCH)
```json
{
"title": "Новое название",
"scheduled_at": "2026-07-25T15:00:00Z",
"duration_minutes": 90,
"is_pinned": true,
"recurrence": null,
"is_closed": false,
"password": null
}
```
**Response (200 OK):** обновленная ConferenceOut
**Коды ошибок:**
- `404` — conference_not_found
- `403` — not_owner
- `422` — invalid_conference_state (например, попытка добавить recurrence к неживой конференции)
---
### DELETE /api/v1/conferences/{conference_id}
**Удалить конференцию — только владелец или администратор.**
**Требует auth:** JWT Bearer token
**Path parameters:**
- `conference_id` (UUID)
**Response (204 No Content)** — без тела
**Коды ошибок:**
- `404` — conference_not_found
- `403` — not_owner
- `409` — conference_active (активную конференцию нельзя удалить, дождитесь завершения)
---
### GET /api/v1/conferences/{conference_id}
**Получить детальную информацию о конференции (с полным составом участников) — владелец, администратор ИЛИ приглашённый.**
Приглашённый определяется так же, как в `GET /my`/`GET /calendar`: строка
`conference_invitees` с `user_id == user.id` либо с `lower(email) == lower(email
пользователя)`. Это расширение касается ТОЛЬКО чтения — `PATCH`/`DELETE`
по-прежнему разрешены только владельцу или администратору (403 приглашённому).
**Требует auth:** JWT Bearer token
**Path parameters:**
- `conference_id` (UUID)
**Response (200 OK):**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"number": "123456789",
"slug": "abc123456",
"title": "Встреча Q3",
"status": "scheduled",
"is_pinned": true,
"is_closed": false,
"scheduled_at": "2026-07-20T14:00:00Z",
"duration_minutes": 60,
"recurrence": { "type": "weekly", "weekdays": [0, 2, 4] },
"summary_recipients": "all",
"owner_id": "550e8400-e29b-41d4-a716-446655440000",
"is_owner": true,
"organizer_name": "Иван Иванов",
"participants": [
{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"email": "ivan@example.com",
"name_user": "Иван Иванов",
"avatar_url": "/media/avatars/550e8400-e29b-41d4-a716-446655440000.jpg"
},
{
"user_id": null,
"email": "external@example.com",
"name_user": null,
"avatar_url": null
}
],
"created_at": "2026-07-16T12:00:00Z"
}
```
**Поля:**
- `owner_id` — UUID владельца конференции
- `is_owner` — true если текущий пользователь владелец
- `organizer_name` — имя организатора (берётся из `users.name_user`)
- `participants` — массив приглашённых (зарегистрированные и внешние). **Заполняется только в этом эндпоинте**; в GET /my и /calendar пустой массив.
**Коды ошибок:**
- `404` — conference_not_found
- `403` — not_owner (пользователь не владелец, не администратор и не приглашён)
---
## Статусы конференции
| Статус | Описание |
|--------|---------|
| `scheduled` | Плановая, время ещё не наступило или никто не вошёл |
| `active` | В настоящий момент идёт (кто-то вошёл или прошло scheduled_at) |
| `ended` | Завершена; истории, фразы, саммари остаются; вход возвращает 410 |
Переходы:
- Мгновенная создание → `active` сразу
- Плановая создание → `scheduled`
- Webhook `room_started``active`
- Webhook `room_finished``ended` (если не pinned) или `scheduled` (если pinned)
- Beat-задача → `ended` если истекло `scheduled_at + duration_minutes` и никто не входил
---
## LiveKit identity
Интеграция с LiveKit на уровне webhook'ов и токенов:
- **Зарегистрированный:** `identity = str(user_id)`, `name = user.name_user`
- **Гость:** `identity = "guest:{guest_access.id}"`, `name = guest_access.display_name`
Email гостя в LiveKit не передаётся (PII не утекает участникам).
---
## Ошибки и коды HTTP
| Код | Описание |
|-----|---------|
| `200` | OK |
| `201` | Created (POST конференции) |
| `204` | No Content (DELETE) |
| `400` | Bad Request (некорректный JSON) |
| `401` | Unauthorized (JWT missing или invalid) |
| `403` | Forbidden (password_required, invalid_password, not_owner) |
| `404` | Not Found (конференция не существует или не найдена) |
| `409` | Conflict (conference_active — нельзя удалить активную) |
| `410` | Gone (conference_ended — вход в завершённую) |
| `422` | Unprocessable Entity (валидация, invalid range на /calendar) |
| `429` | Too Many Requests (rate limit на /resolve, /guest-join) |
---
## Примеры запросов
### Создать мгновенную конференцию
```bash
curl -X POST http://localhost:8000/api/v1/conferences \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"Quick call"}'
```
### Создать плановую с повторением (еженедельно по пн/ср/пт)
```bash
curl -X POST http://localhost:8000/api/v1/conferences \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "Q3 standup",
"scheduled_at": "2026-07-21T09:00:00Z",
"duration_minutes": 30,
"is_pinned": true,
"is_closed": true,
"password": "secret123",
"recurrence": {
"type": "weekly",
"weekdays": [0, 2, 4],
"time_local": "09:00",
"timezone": "Europe/Moscow",
"anchor_date": "2026-07-21",
"duration_minutes": 30
}
}'
```
### Вход по номеру (публичный, без auth)
```bash
curl http://localhost:8000/api/v1/conferences/resolve?q=123456789
```
### Вход гостем
```bash
curl -X POST http://localhost:8000/api/v1/conferences/550e8400-e29b-41d4-a716-446655440000/guest-join \
-H "Content-Type: application/json" \
-d '{
"display_name": "Иван",
"email": "ivan@example.com",
"password": "secret123"
}'
```
---
## Рассылка приглашений
При создании (POST) или изменении (PATCH) конференции отправляются .ics-приглашения:
**Получатели:**
- Организатор (владелец конференции, автоматически)
- Все приглашённые в `participants` (зарегистрированные пользователи и внешние email'ы)
**Содержимое письма:**
- Тема: `"Приглашение: <название конференции>"`
- Вложение `.ics` (iCalendar формат)
- `METHOD:REQUEST` — приглашение
- `VTIMEZONE` — часовой пояс инстанса (для корректного отображения)
- `RRULE` — правило повтора (для закреплённых конференций с `recurrence`)
- `SEQUENCE` — номер версии события
**Логика рассылки:**
- **Создание (POST):** Рассылка всем в `participants` и организатору. `SEQUENCE=0`.
- **Изменение расписания (PATCH `scheduled_at`, `duration_minutes`, `recurrence`, `title`):** Инкрементируется `SEQUENCE`, рассылка повторяется.
- **Изменение только состава участников (PATCH `participants`):** Рассылка отправляется, но `SEQUENCE` **не меняется** (календарные клиенты игнорируют обновления с неизменённым SEQUENCE).
- **Прочие изменения (пароль, `is_closed`, `summary_recipients`):** Рассылка **не отправляется**.
**Внешние приглашённые:**
- Письма отправляются на email'ы без требования аутентификации.
- При входе по ссылке гост вводит имя и опционально email.
- Если email гостя совпадает с приглашённым — он может получить саммари (зависит от `summary_recipients`).
---
## Примечания
- Все `datetime` сохраняются и возвращаются в UTC (ISO 8601, суффикс `Z`)
- Преобразование в локальное время — на клиенте по таймзоне браузера
- `.ics` экспорт включает `VTIMEZONE` для правильной конвертации (`services/ics.py`)
- Номер и slug неизменны всю жизнь конференции и не переиспользуются
- `recurrence` хранится как JSONB (можно запросить всё, развёртка только в памяти)
---
## Ссылки
- [Architecture Overview](../architecture/README.md) — система компонентов
- [Database Schema](../db/schema.md) — таблицы `conferences`, `conference_sessions`, `guest_access`
- [ADR-001: Динамические конференции вместо бронирований](../architecture/adr/001-dynamic-conferences-pivot.md) — обоснование решения
- [Recurrence Service](../../backend/services/recurrence.py) — реализация развёртки

111
docs/api/teams.md Normal file
View File

@@ -0,0 +1,111 @@
# Справочник команд
Публичный API для получения списка команд. Для CRUD операций над командами см. [Администраторский API](./admin.md#команды).
## Быстрый старт
```bash
# Получить список команд
curl http://localhost:8000/api/v1/teams \
-H "Authorization: Bearer $TOKEN"
```
## Эндпоинты
### GET /api/v1/teams
**Получить полный справочник команд (отсортирован по названию).**
**Требует auth:** JWT Bearer token
**Response (200 OK):**
```json
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Backend"
},
{
"id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"name": "Frontend"
},
{
"id": "6ba7b811-9dad-11d1-80b4-00c04fd430c9",
"name": "DevOps"
}
]
```
**Поля:**
- `id` — UUID команды
- `name` — название (уникально)
**Примечание:** Список возвращается для **всех аутентифицированных пользователей** (не зависит от роли). Этот эндпоинт используется:
- На странице профиля пользователя для выбора своей команды (PATCH /api/v1/users/me)
- При создании конференции с приглашением участников (информационно, для фронтенда)
- На администраторской странице управления пользователями
---
## Использование
### На странице профиля
Пользователь может выбрать команду из этого справочника и назначить себе через:
```bash
curl -X PATCH http://localhost:8000/api/v1/users/me \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"team_id":"550e8400-e29b-41d4-a716-446655440000"}'
```
### Администратор
Администратор может управлять командами (создание, редактирование, удаление) через [Администраторский API](./admin.md#команды):
```bash
# Создать команду
curl -X POST http://localhost:8000/api/v1/admin/teams \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"QA"}'
# Переименовать команду
curl -X PATCH http://localhost:8000/api/v1/admin/teams/550e8400-e29b-41d4-a716-446655440000 \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"QA & Testing"}'
# Удалить команду
curl -X DELETE http://localhost:8000/api/v1/admin/teams/550e8400-e29b-41d4-a716-446655440000 \
-H "Authorization: Bearer $ADMIN_TOKEN"
```
---
## Типы данных
### TeamOut
```json
{
"id": "uuid",
"name": "string"
}
```
---
## Примечания
- **Привязка опциональна:** пользователь может работать без команды (`team_id` = null в профиле)
- **Каскадное удаление:** при удалении команды пользователи, состоявшие в ней, остаются (ON DELETE SET NULL), их `team_id` становится null
- **Выбор при регистрации:** настройка `registration_team_choice` (в администраторских настройках) определяет, виден ли выбор команды на публичной форме регистрации
---
## Ссылки
- [Профиль пользователя](./users.md) — использует этот справочник для выбора команды
- [Администраторский API / Команды](./admin.md#команды) — CRUD операции (только админ)
- [Аутентификация / Опции регистрации](./auth.md#get-apiv1authregistration-options) — включает список команд если выбор включён

308
docs/api/users.md Normal file
View File

@@ -0,0 +1,308 @@
# Профиль и аватар пользователя
Эндпоинты управления профилем, загрузкой аватаров и поиска пользователей.
## Быстрый старт
```bash
# 1. Получить профиль
curl http://localhost:8000/api/v1/users/me \
-H "Authorization: Bearer $TOKEN"
# 2. Обновить ФИО и команду
curl -X PATCH http://localhost:8000/api/v1/users/me \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name_user":"Новое имя","team_id":"uuid-team"}'
# 3. Загрузить аватар
curl -X POST http://localhost:8000/api/v1/users/me/avatar \
-H "Authorization: Bearer $TOKEN" \
-F "file=@/path/to/photo.jpg"
# 4. Удалить аватар
curl -X DELETE http://localhost:8000/api/v1/users/me/avatar \
-H "Authorization: Bearer $TOKEN"
# 5. Сменить пароль
curl -X POST http://localhost:8000/api/v1/users/me/password \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"current_password":"oldPassword123","new_password":"newSecurePassword456"}'
# 6. Поиск пользователей (для пикера участников)
curl 'http://localhost:8000/api/v1/users?q=john' \
-H "Authorization: Bearer $TOKEN"
# 7. Получить справочник команд
curl http://localhost:8000/api/v1/teams \
-H "Authorization: Bearer $TOKEN"
```
## Эндпоинты
### GET /api/v1/users/me
**Получить профиль текущего аутентифицированного пользователя.**
**Требует auth:** JWT Bearer token
**Response (200 OK):**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"name_user": "Иван Петров",
"team_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"team_name": "Backend",
"avatar_path": "avatars/550e8400-e29b-41d4-a716-446655440000.jpg",
"avatar_url": "/media/avatars/550e8400-e29b-41d4-a716-446655440000.jpg",
"created_at": "2026-07-16T12:00:00Z"
}
```
**Поля:**
- `email` — адрес электронной почты (read-only)
- `name_user` — отображаемое имя (редактируемое)
- `team_id` — UUID команды (опционально, редактируемое)
- `team_name` — имя команды (null если не привязан)
- `avatar_path` — путь к файлу аватара (null если нет)
- `avatar_url` — готовый URL для отображения; при отсутствии аватара фронтенд показывает заглушку с инициалами
---
### PATCH /api/v1/users/me
**Изменить ФИО и/или команду текущего пользователя; email — read-only.**
**Требует auth:** JWT Bearer token
**Request body:** (все поля опциональны)
```json
{
"name_user": "Новое имя",
"team_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
}
```
**Поля:**
- `name_user` (str, 1..255) — новое ФИО
- `team_id` (UUID или null) — переназначить команду; явное `null` снимает привязку; отсутствие поля команду не трогает
**Response (200 OK):** обновленный профиль (как GET /me)
**Коды ошибок:**
- `404` — team_not_found (если передан несуществующий `team_id`)
- `422` — Unprocessable Entity (валидация: name_user пуст и т.д.)
---
### POST /api/v1/users/me/avatar
**Загрузить аватар текущего пользователя.**
**Требует auth:** JWT Bearer token
**Request body:** `multipart/form-data`
- `file` — файл изображения (обязателен)
**Требования:**
- **Формат:** JPEG, PNG или WebP (проверяется по magic bytes, не по расширению)
- **Максимальный размер:** 2 МБ
- Старый аватар автоматически удаляется при загрузке нового
**Response (200 OK):** обновленный профиль (как GET /me, с новым `avatar_url`)
**Коды ошибок:**
- `413` — avatar_too_large (файл больше 2 МБ)
- `415` — avatar_invalid_type (не JPEG/PNG/WebP; проверяется по magic bytes)
- `422` — Unprocessable Entity (невалидный multipart и т.д.)
**Примеры:**
```bash
# JPEG
curl -X POST http://localhost:8000/api/v1/users/me/avatar \
-H "Authorization: Bearer $TOKEN" \
-F "file=@photo.jpg"
# PNG
curl -X POST http://localhost:8000/api/v1/users/me/avatar \
-H "Authorization: Bearer $TOKEN" \
-F "file=@photo.png"
# WebP
curl -X POST http://localhost:8000/api/v1/users/me/avatar \
-H "Authorization: Bearer $TOKEN" \
-F "file=@photo.webp"
```
---
### DELETE /api/v1/users/me/avatar
**Удалить аватар текущего пользователя.**
**Требует auth:** JWT Bearer token
**Response (204 No Content)** — без тела
**Примечание:** После удаления `avatar_path` становится `null`, фронтенд отображает заглушку с инициалами.
---
### POST /api/v1/users/me/password
**Сменить пароль текущего пользователя.**
**Требует auth:** JWT Bearer token
**Request body:**
```json
{
"current_password": "oldPassword123",
"new_password": "newSecurePassword456"
}
```
**Поля:**
- `current_password` (str) — текущий пароль (обязательно, для верификации)
- `new_password` (str, ≥8) — новый пароль (политика как при регистрации: минимум 8 символов)
**Response (204 No Content)** — без тела
**Коды ошибок:**
- `400` — invalid_current_password (текущий пароль не совпадает)
- `422` — Unprocessable Entity (новый пароль короче 8 символов)
**Примечание:** Refresh-сессии сознательно **не отзываются** (см. ADR-005 `docs/architecture/adr/005-password-reset-deferred.md`) — пользователь останется залогинен на других устройствах. Массовый отзыв всех сессий появится вместе со сбросом пароля по email (v0.1.0).
---
### GET /api/v1/users
**Поиск пользователей для пикера участников конференции.**
**Требует auth:** JWT Bearer token
**Query параметры:**
- `q` (str, опционально) — текст для поиска (по имени и email)
**Поведение:**
- Без `q` — возвращает полный список (с ограничением ~20 результатов)
- С `q` — полнотекстовый поиск (также ~20 результатов)
- Поиск регистронезависим
**Response (200 OK):**
```json
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "ivan@example.com",
"name_user": "Иван Петров",
"avatar_url": "/media/avatars/550e8400-e29b-41d4-a716-446655440000.jpg"
},
{
"id": "6ba7b811-9dad-11d1-80b4-00c04fd430c8",
"email": "maria@example.com",
"name_user": "Мария Сидорова",
"avatar_url": null
}
]
```
**Примеры:**
```bash
# Полный список
curl http://localhost:8000/api/v1/users \
-H "Authorization: Bearer $TOKEN"
# Поиск по имени
curl 'http://localhost:8000/api/v1/users?q=ivan' \
-H "Authorization: Bearer $TOKEN"
# Поиск по email
curl 'http://localhost:8000/api/v1/users?q=example.com' \
-H "Authorization: Bearer $TOKEN"
```
---
## Аватары
### Хранение
Аватары хранятся в каталоге `MEDIA_ROOT/avatars/`:
```
MEDIA_ROOT/
└── avatars/
├── 550e8400-e29b-41d4-a716-446655440000.jpg
├── 6ba7b811-9dad-11d1-80b4-00c04fd430c8.png
└── ...
```
**Адресация:**
- Backend сохраняет: `user.avatar_path = "avatars/{user_id}.{ext}"`
- Frontend отображает: `avatar_url = "/media/avatars/{user_id}.{ext}"`
- Nginx раздаёт напрямую (location `/media/` → alias `MEDIA_ROOT`)
### Валидация
**Magic bytes (не расширение):**
- JPEG: `FF D8 FF`
- PNG: `89 50 4E 47`
- WebP: `52 49 46 46 ... 57 45 42 50`
**Размер:** max 2 МБ (413 Payload Too Large)
### Заглушка (дефолт)
Если `avatar_path` = `null`:
- Фронтенд отображает цветную заглушку с инициалами (2 буквы ФИО)
- Цвет выбирается детерминированно по хэшу `user_id` (всегда одинаковый для пользователя)
---
## Типы данных
### UserProfileOut
```json
{
"id": "uuid",
"email": "string",
"name_user": "string",
"team_id": "uuid or null",
"team_name": "string or null",
"avatar_path": "string or null",
"avatar_url": "string or null",
"created_at": "datetime"
}
```
### UserListItemOut
```json
{
"id": "uuid",
"email": "string",
"name_user": "string",
"avatar_url": "string or null"
}
```
---
## Примечания
- **Email read-only:** адрес электронной почты менять нельзя (требует переверификации, которая не реализована в v0.0.1)
- **Таймзона:** все `created_at` сохраняются в UTC (ISO 8601)
- **Команда опциональна:** пользователь может работать без привязки к команде (`team_id` = null)
- **Безопасность:** пароли никогда не передаются в API; для смены пароля используйте `POST /api/v1/users/me/password`. Сброс пароля по email появится в v0.1.0 (см. ADR-005)
---
## Ссылки
- [Справочник команд](./teams.md) — GET /api/v1/teams
- [Администраторский API](./admin.md) — GET/PATCH /api/v1/admin/users/{id}, POST /api/v1/admin/users/{id}/avatar
- [Аутентификация](./auth.md) — регистрация, логин
- [Конференции](./conferences.md) — параметр `participants` при создании/редактировании