Files
vidconf/docs/api/conferences.md

541 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# API конференций
Полная справка по эндпоинтам динамических конференций (см. 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) — реализация развёртки