first commit
This commit is contained in:
0
docs/api/.gitkeep
Normal file
0
docs/api/.gitkeep
Normal file
184
docs/api/README.md
Normal file
184
docs/api/README.md
Normal 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
578
docs/api/admin.md
Normal 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
513
docs/api/auth.md
Normal 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
272
docs/api/chat.md
Normal 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
540
docs/api/conferences.md
Normal 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
111
docs/api/teams.md
Normal 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
308
docs/api/users.md
Normal 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` при создании/редактировании
|
||||
Reference in New Issue
Block a user