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

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

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

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