# 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) — реализация развёртки
