# Схема БД

## ER-диаграмма

```mermaid
erDiagram
    USERS ||--o{ CONFERENCES : owns
    USERS ||--o{ CONFERENCE_PARTICIPANTS : joins
    USERS ||--o{ CONFERENCE_INVITEES : invites
    USERS ||--o{ PHRASES : speaks
    USERS ||--o{ CHAT_MESSAGES : "sends (author)"
    USERS ||--o{ EMAIL_VERIFICATION_TOKENS : receives
    CONFERENCES ||--o{ CONFERENCE_INVITEES : "has invitees"
    CONFERENCES ||--o{ CONFERENCE_SESSIONS : "has sessions"
    CONFERENCES ||--o{ GUEST_ACCESS : "may allow"
    CONFERENCES ||--o{ EMAIL_DELIVERIES : "invitations"
    CONFERENCE_SESSIONS ||--o{ CONFERENCE_PARTICIPANTS : tracks
    CONFERENCE_SESSIONS ||--o{ PHRASES : contains
    CONFERENCE_SESSIONS ||--o{ CHAT_MESSAGES : contains
    CONFERENCE_SESSIONS ||--o{ EMAIL_DELIVERIES : "summaries"
    GUEST_ACCESS ||--o{ CONFERENCE_PARTICIPANTS : "may join"
    GUEST_ACCESS ||--o{ CHAT_MESSAGES : "sends (author)"
    LIVEKIT_WEBHOOK_EVENTS ||--o{ CONFERENCE_SESSIONS : "triggers"
    INSTANCE_SETTINGS ||--|| CONFERENCES : "configure"
    TEAMS ||--o{ USERS : "groups"

    USERS {
        uuid id PK
        string email UK
        string name_user
        text password_hash
        string role "admin|user"
        boolean email_verified
        boolean is_blocked
        uuid team_id FK "nullable"
        string avatar_path "nullable"
        datetime created_at
    }

    EMAIL_VERIFICATION_TOKENS {
        uuid id PK
        uuid user_id FK
        string token_hash UK
        datetime expires_at
        datetime used_at "nullable"
        datetime created_at
    }

    CONFERENCES {
        uuid id PK
        string number UK "9 digits"
        string slug UK "base64url"
        string title "nullable"
        uuid owner_id FK "nullable"
        string status "enum: scheduled|active|ended"
        boolean is_pinned
        boolean is_closed
        text password_hash "nullable"
        datetime scheduled_at "nullable"
        integer duration_minutes "nullable"
        jsonb recurrence "nullable"
        datetime ended_at "nullable"
        datetime created_at
    }

    CONFERENCE_INVITEES {
        uuid id PK
        uuid conference_id FK
        uuid user_id FK "nullable, для зарег. пользователей"
        string email "nullable, для внешних"
        datetime created_at
        "CHECK: ровно один из user_id/email"
        "UNIQUE: (conference_id, user_id)"
        "UNIQUE: (conference_id, lower(email))"
    }

    CONFERENCE_SESSIONS {
        uuid id PK
        uuid conference_id FK
        string title "nullable"
        datetime t_start
        datetime t_end "nullable"
        text summary_data "nullable"
        string pipeline_status "enum"
        datetime created_at
    }

    GUEST_ACCESS {
        uuid id PK
        uuid conference_id FK
        string display_name
        string email "nullable"
        datetime created_at
    }

    CONFERENCE_PARTICIPANTS {
        uuid id PK
        uuid session_id FK
        uuid user_id FK "nullable"
        uuid guest_id FK "nullable"
        datetime joined_at
        datetime left_at "nullable"
    }

    SESSION_AUDIO_TRACKS {
        uuid id PK
        uuid session_id FK
        uuid participant_id FK
        string track_sid
        string egress_id "nullable"
        text file_path "nullable"
        string status "enum"
        datetime started_at
        datetime ended_at "nullable"
        jsonb segments "nullable"
    }

    PHRASES {
        bigint id PK "Identity"
        uuid participant_id FK
        uuid session_id FK
        text data
        datetime t_start
        datetime t_end
    }

    CHAT_MESSAGES {
        bigint id PK "Identity"
        uuid session_id FK
        uuid user_id FK "nullable"
        uuid guest_access_id FK "nullable"
        string author_name "255 chars"
        text text
        datetime created_at
    }

    LIVEKIT_WEBHOOK_EVENTS {
        string event_id PK
        string event_type
        datetime received_at
    }

    INSTANCE_SETTINGS {
        string key PK
        jsonb value
        datetime updated_at
    }

    EMAIL_DELIVERIES {
        uuid id PK
        uuid session_id FK "nullable"
        uuid conference_id FK "nullable"
        string recipient_email
        string kind "summary|invitation"
        datetime sent_at
    }
```

## Таблицы

### users
Зарегистрированные пользователи приложения.

| Поле | Тип | Описание |
|------|-----|---------|
| `id` | UUID | Primary key, генерируется как `gen_random_uuid()` |
| `email` | VARCHAR(255) | Уникальный адрес электронной почты |
| `name_user` | VARCHAR(255) | Имя пользователя |
| `password_hash` | TEXT | Argon2-хэш пароля |
| `role` | VARCHAR(16) | 'admin' или 'user'; проверка CHECK |
| `email_verified` | BOOLEAN | Флаг подтверждения почты |
| `is_blocked` | BOOLEAN | Флаг блокировки администратором; заблокированный пользователь получает 401 немедленно, без ожидания истечения access-токена |
| `team_id` | UUID | FK → teams, nullable; `ON DELETE SET NULL` — удаление команды не удаляет пользователей |
| `avatar_path` | VARCHAR(512) | Путь к загруженному аватару (относительно `MEDIA_ROOT`); `NULL` — заглушка с инициалами на фронте; пример: `avatars/550e8400-e29b-41d4-a716-446655440000.jpg` |
| `created_at` | TIMESTAMPTZ | UTC-время создания |

### teams
Справочник команд для группировки пользователей.

| Поле | Тип | Описание |
|------|-----|---------|
| `id` | UUID | Primary key, генерируется как `gen_random_uuid()` |
| `name` | VARCHAR(255) | Название команды; уникально |
| `created_at` | TIMESTAMPTZ | UTC-время создания |

**Логика:** удаление команды не каскадирует на пользователей — `users.team_id` просто обнуляется (`ON DELETE SET NULL`); привязка необязательна.

### email_verification_tokens
Одноразовые токены подтверждения email при регистрации.

| Поле | Тип | Описание |
|------|-----|---------|
| `id` | UUID | Primary key, генерируется как `gen_random_uuid()` |
| `user_id` | UUID | FK → users; CASCADE удаление |
| `token_hash` | TEXT | SHA256-хэш одноразового токена (hex); уникален |
| `expires_at` | TIMESTAMPTZ | Срок действия (UTC); по умолчанию 24 часа от создания |
| `used_at` | TIMESTAMPTZ | Время использования; NULL если не подтверждён |
| `created_at` | TIMESTAMPTZ | UTC-время создания |

**Ограничения:**
- `UNIQUE(token_hash)` — уникальный индекс на хэш токена
- `INDEX(user_id)` — для быстрого поиска токенов пользователя

**Логика:**
- При регистрации создаётся новый токен; клиент получает открытый токен в письме
- Сервер хранит только SHA256-хэш (токен не может быть восстановлен из БД)
- Первое использование (POST /verify-email) заполняет `used_at`, устанавливает `user.email_verified=true`
- Повторное использование того же токена → 400 (used_at IS NOT NULL)

### conferences
Конференция — постоянная сущность с номером и постоянной ссылкой (slug). Может быть мгновенной или плановой с повторением (ADR-001).

**Миграция:** Таблица `conferences` переименована в `conference_sessions` (см. ниже); новая таблица `conferences` создана миграцией `f418dd65e7b1`.

| Поле | Тип | Описание |
|------|-----|---------|
| `id` | UUID | Primary key, генерируется как `gen_random_uuid()` |
| `number` | VARCHAR(9) | 9 десятичных цифр, первая 1..9, уникален; генерируется с retry при коллизии |
| `slug` | VARCHAR(22) | `secrets.token_urlsafe(8)` — 11 base64url-символов; используется как имя комнаты в LiveKit и часть URL `/j/{slug}` |
| `title` | VARCHAR(255) | Опциональное название конференции |
| `owner_id` | UUID | FK → users; SET NULL при удалении пользователя; может быть NULL для исторических данных |
| `status` | ENUM | `scheduled` (плановая, время ещё не наступило), `active` (идёт сейчас), `ended` (завершена; история остаётся) |
| `is_pinned` | BOOLEAN | Закреплена ли (видна в «Моих конференциях», может повторяться) |
| `is_closed` | BOOLEAN | Требуется ли пароль для входа |
| `password_hash` | TEXT | Argon2-хэш пароля (только если is_closed=true); CHECK: `is_closed=false OR password_hash IS NOT NULL` |
| `scheduled_at` | TIMESTAMPTZ | Время запуска (UTC); NULL для мгновенных |
| `duration_minutes` | INT | Ожидаемая длительность в минутах; опционально, для информации |
| `recurrence` | JSONB | RecurrenceRule (JSON с type/weekdays/day_of_month/interval_days/anchor_date/time_local/timezone/duration_minutes); только если is_pinned=true; CHECK: `recurrence IS NULL OR is_pinned = true` |
| `ended_at` | TIMESTAMPTZ | Время завершения (UTC); заполняется при статусе → ended |
| `summary_recipients` | VARCHAR(16) | Переопределение рассылки саммари для конференции: `'all'` (всем участникам и гостям с email), `'owner'` (только организатору), NULL (используется дефолт инстанса) |
| `ics_sequence` | INT | Счётчик изменений расписания: растёт при правке `scheduled_at`, `duration_minutes`, `recurrence`, `title`; пишется в VEVENT SEQUENCE для обновления приглашений календарными клиентами |
| `created_at` | TIMESTAMPTZ | UTC-время создания |

**Ограничения:**
- `UNIQUE(number)` — уникальность номера
- `UNIQUE(slug)` — уникальность постоянной ссылки
- CHECK: `is_closed = false OR password_hash IS NOT NULL`
- CHECK: `recurrence IS NULL OR is_pinned = true`

### conference_sessions
Один запуск конференции, единица AI-пайплайна пост-обработки.

**Миграция:** До миграции `f418dd65e7b1` это была таблица `conferences`; переименована в `conference_sessions`, связь `conference_id` добавлена.

| Поле | Тип | Описание |
|------|-----|---------|
| `id` | UUID | Primary key |
| `conference_id` | UUID | FK → conferences; CASCADE удаление |
| `title` | VARCHAR(255) | Снапшот названия конференции на момент запуска (опционально) |
| `t_start` | TIMESTAMPTZ | Время начала сеанса (UTC) |
| `t_end` | TIMESTAMPTZ | Время окончания сеанса (UTC); опционально, заполняется при завершении |
| `summary_data` | TEXT | JSON со сводкой (заполняется после summarize-шага); опционально |
| `pipeline_status` | ENUM | Статус пайплайна: `recording` → `transcribing` → `summarizing` → `notified` / `failed` |
| `created_at` | TIMESTAMPTZ | UTC-время создания |

**Индексы:**
- `(conference_id, t_start)` для поиска сеансов конференции
- `(pipeline_status)` для поиска активных обработок

Статус-машина пост-обработки живёт в этой таблице (см. ADR-001).

### guest_access
Гость, представившийся при входе в конференцию без аутентификации (ADR-001, п.6).

| Поле | Тип | Описание |
|------|-----|---------|
| `id` | UUID | Primary key |
| `conference_id` | UUID | FK → conferences; CASCADE удаление |
| `display_name` | VARCHAR(255) | Отображаемое имя гостя (обязательно) |
| `email` | VARCHAR(320) | Адрес электронной почты (опционально; для рассылки саммари) |
| `created_at` | TIMESTAMPTZ | UTC-время создания записи (при первом входе гостем) |

**Назначение:**
- Гость входит в конференцию без аккаунта (публичный эндпоинт `/guest-join`)
- LiveKit identity: `guest:{guest_access.id}`
- Email не передаётся в LiveKit (PII — только в БД)

### conference_participants
Отслеживает присутствие участника (пользователя ИЛИ гостя) в сеансе конференции (ADR-001, п.6).

**Миграция:** Колонка `conference_id` переименована в `session_id` (`f418dd65e7b1`); `user_id` стал nullable; добавлены `guest_id` и CHECK для ровно одного identity.

| Поле | Тип | Описание |
|------|-----|---------|
| `id` | UUID | Primary key |
| `session_id` | UUID | FK → conference_sessions; CASCADE удаление |
| `user_id` | UUID | FK → users; NULL для гостей; nullable |
| `guest_id` | UUID | FK → guest_access; NULL для зарегистрированных; nullable |
| `joined_at` | TIMESTAMPTZ | Время входа (UTC) |
| `left_at` | TIMESTAMPTZ | Время выхода (UTC); опционально |

**Ограничения:**
- CHECK: `(user_id IS NOT NULL)::int + (guest_id IS NOT NULL)::int = 1` — ровно одно из user_id/guest_id заполнено

**Индексы:**
- `(session_id)` для поиска участников сеанса

### conference_invitees

Приглашённые на конференцию участники (зарегистрированные пользователи или внешние email'ы) — ADR-003.

**Миграция:** `d87681e12784`

| Поле | Тип | Описание |
|------|-----|---------|
| `id` | UUID | Primary key, генерируется как `gen_random_uuid()` |
| `conference_id` | UUID | FK → conferences; CASCADE удаление |
| `user_id` | UUID | FK → users; nullable; CASCADE удаление; заполнено для зарегистрированных пользователей |
| `email` | VARCHAR(255) | Email для внешних приглашённых; nullable; нормализуется в lower-case |
| `created_at` | TIMESTAMPTZ | UTC-время создания приглашения |

**Ограничения:**
- CHECK: `(user_id IS NOT NULL)::int + (email IS NOT NULL)::int = 1` — ровно один из user_id/email должен быть заполнен (единственная identity приглашённого)
- **UNIQUE INDEX `uq_conference_invitees_user`** на `(conference_id, user_id)` WHERE `user_id IS NOT NULL` — один приглашённый пользователь на конференцию
- **UNIQUE INDEX `uq_conference_invitees_email`** на `(conference_id, lower(email))` WHERE `email IS NOT NULL` — один внешний email на конференцию (регистронезависимо)

**Назначение:**
- Хранение состава приглашённых участников, заданного организатором при создании/редактировании конференции
- **Организатор НЕ хранится в этой таблице** — он выводится из `conferences.owner_id` и всегда в составе (инвариант обеспечен конструктивно, без триггеров)
- **Не путать с `conference_participants`** — та таблица фиксирует ФАКТИЧЕСКОЕ присутствие в сеансе; эта — планируемый состав

**Семантика:**
- Дедупликация при POST/PATCH конференции: если организатор передан в массиве participants, он автоматически вычищается (не хранится дважды)
- Внешние приглашённые (email) при входе по ссылке создают запись в `guest_access`, которая затем связывается с `conference_participants` сеанса

### session_audio_tracks

Аудиодорожка сеанса, записанная LiveKit Track Egress. Каждая строка соответствует одному аудиотреку (микрофону) одного участника.

**Таблица используется для:**
- Маппинга файлов записи к участникам сеанса (включая гостей без user_id)
- Отслеживания статуса транскрибации каждого трека (recording → recorded → transcribed / failed)
- Хранения сегментов Whisper в JSONB (`segments`) как промежуточного артефакта пайплайна

| Поле | Тип | Описание |
|------|-----|---------|
| `id` | UUID | Primary key, генерируется как `gen_random_uuid()` |
| `session_id` | UUID | FK → conference_sessions; CASCADE удаление |
| `participant_id` | UUID | FK → conference_participants; CASCADE удаление; NOT NULL (атрибуция к участнику сеанса, см. ADR-002) |
| `track_sid` | VARCHAR(64) | ID трека в LiveKit (уникален в пределах сеанса с session_id) |
| `egress_id` | VARCHAR(64) | ID egress-задачи в LiveKit; заполняется при старте egress; может быть NULL |
| `file_path` | TEXT | Абсолютный путь к файлу на диске (например, `/recordings/{session_id}/{participant_id}_{track_sid}.ogg`); заполняется при завершении egress |
| `status` | ENUM | Статус трека: `recording` (egress активен) → `recorded` (файл готов) → `transcribed` (Whisper завершил) / `failed` (ошибка на любом шаге) |
| `started_at` | TIMESTAMPTZ | UTC-время старта egress (из EgressInfo) |
| `ended_at` | TIMESTAMPTZ | UTC-время завершения egress; NULL пока трек в статусе `recording` |
| `segments` | JSONB | Список объектов `{start: float, end: float, text: string}` — результат faster-whisper для этого трека; NULL пока не транскрибирован |

**Ограничения:**
- `UNIQUE(session_id, track_sid)` — один трек на сеанс; дедупликация при повторных `track_published`
- Индексы: `(session_id)`, `(session_id, status)` для поиска активных и готовых треков

**Инвариант:**
- Независимый статус трека (отдельно от `conference_sessions.pipeline_status`)
- Тайминги сегментов в `segments` — относительно начала файла (не UTC); абсолютный расчёт: `session.t_start + timedelta(seconds=segment.start)`

### phrases

Восстановленные транскрипт-фразы (результат faster-whisper + реконструкция по алгоритму ТЗ §1.3).

**Изменение:** Колонка `user_id` заменена на `participant_id` (FK → conference_participants), чтобы атрибутировать фразы гостям без user_id (ADR-002).

| Поле | Тип | Описание |
|------|-----|---------|
| `id` | BIGINT | Primary key, Identity(always=True) |
| `participant_id` | UUID | FK → conference_participants; CASCADE удаление; NOT NULL; атрибуция к участнику сеанса |
| `session_id` | UUID | FK → conference_sessions; CASCADE удаление |
| `data` | TEXT | Текст фразы (результат `build_phrases()` — может быть несколько сегментов Whisper одного спикера подряд) |
| `t_start` | TIMESTAMPTZ | Начало фразы (UTC); рассчитывается как `session.t_start + (segment.start + track_offset)` |
| `t_end` | TIMESTAMPTZ | Конец фразы (UTC) |

**Индексы:**
- `(session_id, t_start)` для хронологического поиска фраз в сеансе

**Как получить пользователя/гостя фразы:**
```sql
SELECT p.data, u.name_user, g.display_name
FROM phrases p
JOIN conference_participants cp ON p.participant_id = cp.id
LEFT JOIN users u ON cp.user_id = u.id
LEFT JOIN guest_access g ON cp.guest_id = g.id
WHERE p.session_id = $1
ORDER BY p.t_start;
```

### chat_messages
Текстовые сообщения, отправленные во время сеанса конференции (WS чат).

**Миграции:** 
- `f418dd65e7b1`: колонка `conference_id` переименована в `session_id`
- `504791847d4f`: добавлены `guest_access_id` и `author_name` для поддержки гостевых авторов

| Поле | Тип | Описание |
|------|-----|---------|
| `id` | BIGINT | Primary key, Identity(always=True), уникален в пределах инстанса |
| `session_id` | UUID | FK → conference_sessions; CASCADE удаление |
| `user_id` | UUID | FK → users; nullable; заполнено для авторов-пользователей |
| `guest_access_id` | UUID | FK → guest_access; nullable; CASCADE удаление; заполнено для авторов-гостей |
| `author_name` | VARCHAR(255) | Снапшот отображаемого имени автора из LiveKit access-токена на момент отправки; NOT NULL (переживает переименование пользователя или удаление гостевой записи) |
| `text` | TEXT | Текст сообщения (1..2000 символов) |
| `created_at` | TIMESTAMPTZ | UTC-время создания |

**Ограничения:**
- CHECK: `user_id IS NOT NULL OR guest_access_id IS NOT NULL` — ровно один из user_id/guest_access_id должен быть заполнен (аналогично `conference_participants`, ADR-001 п.6)

**Индексы:**
- `(session_id, created_at)` для хронологического поиска сообщений в сеансе

**Назначение:** Хранение истории чата конференции для отдачи последних N сообщений при подключении нового клиента; публикация в Redis pub/sub для broadcast остальным клиентам

### instance_settings
Настройки инстанса как key-value хранилище (JSONB). Бутстрап из `config/plugins.yaml` при старте backend'а (однократный, идемпотентный импорт).

| Поле | Тип | Описание |
|------|-----|---------|
| `key` | TEXT | Primary key; имя настройки (`transcriber`, `summarizer`, `chat`, `ai_level`, `summary_recipients`, `display_timezone`, `registration_team_choice`, `registration_email_domain`) |
| `value` | JSONB | Значение настройки (структура зависит от ключа) |
| `updated_at` | TIMESTAMPTZ | UTC-время последнего обновления |

**Назначение:**
- Централизованное хранилище конфигурации инстанса (переносит дефолты из `config/plugins.yaml` в БД)
- Административный интерфейс может менять настройки без рестарта приложения
- Воркеры Celery читают конфигурацию на старте каждой задачи (fallback на YAML при пустой таблице)

**Ключи и типы значений (примеры):**
```json
{
  "transcriber": {"enabled": true, "provider": "faster-whisper-cpu", "language": "ru", ...},
  "summarizer": {"enabled": true, "provider": "qwen-local", "chunk_minutes": 20, ...},
  "chat": {"enabled": false},
  "ai_level": {"level": "min"},
  "summary_recipients": {"mode": "all"},
  "display_timezone": {"tz": "Europe/Moscow"},
  "registration_team_choice": {"enabled": true},
  "registration_email_domain": {"enabled": true, "domain": "example.com"}
}
```

### email_deliveries
Журнал отправленных писем (саммари и приглашения). Идемпотентность рассылки саммари и отслеживание доставки.

| Поле | Тип | Описание |
|------|-----|---------|
| `id` | UUID | Primary key, генерируется как `gen_random_uuid()` |
| `session_id` | UUID | FK → conference_sessions; NULL для приглашений; заполняется для саммари |
| `conference_id` | UUID | FK → conferences; NULL для саммари; заполняется для приглашений |
| `recipient_email` | VARCHAR(320) | Email получателя (хранится в `lower()` — нормализованный) |
| `kind` | VARCHAR(16) | Тип письма: `'summary'` (саммари сеанса), `'invitation'` (.ics приглашение) |
| `sent_at` | TIMESTAMPTZ | UTC-время отправки (server_default=now()) |

**Ограничения:**
- CHECK: `kind IN ('summary', 'invitation')`
- CHECK: `(kind = 'summary') = (session_id IS NOT NULL)` — саммари обязана иметь session_id
- CHECK: `(kind = 'invitation') = (conference_id IS NOT NULL)` — приглашение обязано иметь conference_id
- **UNIQUE INDEX `uq_email_deliveries_summary`** на `(session_id, recipient_email)` WHERE `kind = 'summary'` — гарантирует, что один адресат получит саммри сеанса один раз (идемпотентность повторной отправки)
- INDEX `ix_email_deliveries_conference` на `conference_id` для быстрого поиска истории приглашений

**Назначение:**
- **Для саммари:** гарантирует идемпотентность — повторный запуск `notify_session` отправляет письмо только тем адресатам, которые ещё не в таблице
- **Для приглашений:** журнал, без unique (изменение расписания конференции переслёт приглашение, допуская дублирование в истории)

### livekit_webhook_events
Журнал идемпотентной доставки webhook-событий от LiveKit.

| Поле | Тип | Описание |
|------|-----|---------|
| `event_id` | VARCHAR(255) | Primary key (уникальный ID события от LiveKit) |
| `event_type` | VARCHAR(64) | Тип события: `room_started`, `participant_joined`, `participant_left`, `room_finished` и др. |
| `received_at` | TIMESTAMPTZ | UTC-время получения события |

**Назначение:**
- Дедупликация: каждое webhook-событие от LiveKit имеет уникальный `event_id`
- При получении события выполняется `INSERT ... ON CONFLICT DO NOTHING` по primary key `event_id`
- Повторная доставка того же события отклоняется на уровне БД; ответ 200 всё равно отправляется (идемпотентность)

## Ключевые решения архитектуры

### UUID как primary key
Все таблицы, кроме `phrases` и `chat_messages`, используют `UUID` генерируемые функцией `gen_random_uuid()`. Это обеспечивает:
- Глобальную уникальность без синхронизации
- Предсказуемую нагрузку на индексы
- Возможность распределённых вставок

### BIGINT Identity для phrases и chat_messages
Высокочастотные таблицы используют `BIGINT Identity(always=True)` — быстрее и проще для очередей обработки фраз/сообщений.

### Номер и slug конференции (9 цифр + base64url)
- **Номер:** 9 десятичных цифр, первая 1..9 (энтропия ≈2^29.75); генерируется с retry при коллизии; задача перебора при rate limit ≈60+ дней; резолв отвечает единообразным 404 (см. ADR-001, п.4)
- **Slug:** `secrets.token_urlsafe(8)` — 11 base64url-символов (64 бита энтропии); используется как имя комнаты в LiveKit и часть URL `/j/{slug}`

### Расширение btree_gist (не используется)
**Миграция `f418dd65e7b1`:** расширение `btree_gist` больше не используется (EXCLUDE constraint на `room_bookings` снят с удалением таблицы, см. ADR-001). Расширение остаётся установленным в БД (безвредно, миграция обратима).

### Все временные метки в UTC
Все столбцы `DateTime(timezone=True)` хранят время в UTC на сервере. Конвертация в локальное время происходит на клиенте на основе часового пояса браузера. iCalendar-экспорт (`.ics`) включает `VTIMEZONE`.

### Enum pipeline_status

Статус пост-обработки сеанса (единица пайплайна) отслеживает жизненный цикл от записи через транскрибацию и суммаризацию до уведомления или ошибки.

**Состояния машины:**

```
recording → transcribing → summarizing → notified / failed
```

| Статус | Описание | Переход | Ответственный |
|--------|---------|---------|---------------|
| `recording` | Сеанс идёт, LiveKit Egress записывает треки | Автоматический при `t_start` сеанса | Backend (создание сеанса) |
| `transcribing` | Транскрибация в процессе | При `run_pipeline()`, после завершения egress | Воркер `run_pipeline` |
| `summarizing` | Суммаризация в процессе | После успешной транскрибации и реконструкции фраз | Воркер `run_pipeline` |
| `notified` | Уведомление отправлено | После успешной суммаризации и отправки email | Воркер уведомлений |
| `failed` | Ошибка на одном из шагов (пайплайн остановлен) | При сбое воркера или если все треки провалены | Воркер `run_pipeline` |

**Идемпотентность:** каждый шаг пайплайна проверяет текущий `pipeline_status` перед началом и пропускает уже завершённые шаги (guard от повторных запусков).

**Пример восстановления после сбоя:**
- Воркер транскрибации упал посеридине обработки треков
- При повторном запуске `run_pipeline(session_id)` проверяет `pipeline_status` = `transcribing`
- Пропускает уже транскрибированные треки (у которых `segments IS NOT NULL`)
- Завершает оставшиеся треки и переходит в `summarizing`

### Идемпотентность через статусы
Каждый шаг пайплайна (транскрибация, суммаризация, уведомление) должен быть идемпотентным: повторное выполнение не приведёт к дублированию или ошибкам. Это достигается через проверку текущего `pipeline_status` перед началом шага.

---

## Миграции

### Миграция d87681e12784

**Участники конференций и аватары пользователей:**

1. **Новая таблица `conference_invitees`** (ADR-003) — приглашённые участники конференции
   - `id` (UUID PK), `conference_id` (FK), `user_id` (nullable FK), `email` (nullable VARCHAR), `created_at`
   - CHECK: ровно одна identity (`user_id` ИЛИ `email`)
   - Частичные UNIQUE индексы по (conference_id, user_id) и (conference_id, lower(email))
   - Организатор не хранится в таблице (выводится из `conferences.owner_id`)

2. **Колонка в `users`:** `avatar_path` (VARCHAR(512), nullable) — путь к загруженному аватару
   - Пример: `avatars/550e8400-e29b-41d4-a716-446655440000.jpg`
   - NULL → заглушка с инициалами на фронте
   - Удаление аватара вычищает файл с диска и обнуляет поле

**SQL:**
```sql
CREATE TABLE conference_invitees (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    conference_id UUID NOT NULL REFERENCES conferences(id) ON DELETE CASCADE,
    user_id UUID REFERENCES users(id) ON DELETE CASCADE,
    email VARCHAR(255),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CHECK ((user_id IS NOT NULL)::int + (email IS NOT NULL)::int = 1)
);
CREATE UNIQUE INDEX uq_conference_invitees_user 
  ON conference_invitees(conference_id, user_id) WHERE user_id IS NOT NULL;
CREATE UNIQUE INDEX uq_conference_invitees_email 
  ON conference_invitees(conference_id, lower(email)) WHERE email IS NOT NULL;

ALTER TABLE users ADD COLUMN avatar_path VARCHAR(512);
```

### Миграция 504791847d4f

**Поддержка гостевых авторов в чате:**

1. **Колонки в `chat_messages`:**
   - `guest_access_id` (UUID, nullable) — FK → `guest_access.id`, `ON DELETE CASCADE`; заполнено для авторов-гостей
   - `author_name` (VARCHAR(255), NOT NULL) — снапшот имени автора из LiveKit access-токена на момент отправки; добавляется nullable, backfill из `users.name_user` для уже существующих строк (все они с `user_id`), затем ужесточается до `NOT NULL`

2. **Изменение `chat_messages.user_id`:**
   - Переходит в nullable (вместо NOT NULL), т.к. автором может быть гость

3. **Check-констрейнт:**
   - `ck_chat_messages_author` — `user_id IS NOT NULL OR guest_access_id IS NOT NULL` (ровно один из двух должен быть заполнен)

**Назначение:** WS-чат поддерживает как пользователей, так и гостей как авторов сообщений, с сохранением снапшота имени (для истории и чата).

### Миграция 9d37822e4513

**Справочник команд:**

1. **Новая таблица `teams`** — `id` (UUID PK), `name` (уникально), `created_at`
2. **Колонка в `users`:** `team_id` (UUID, nullable) — FK → `teams.id`, `ON DELETE SET NULL`

**SQL:**
```sql
CREATE TABLE teams (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL UNIQUE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE users ADD COLUMN team_id UUID;
ALTER TABLE users ADD CONSTRAINT fk_users_team_id_teams
    FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE SET NULL;
```

### Миграция 5970bf64fc43

**Настройки инстанса и уведомления:**

1. **Новая таблица `instance_settings`** — key-value хранилище настроек (JSONB) с бутстрапом из `config/plugins.yaml`
2. **Новая таблица `email_deliveries`** — идемпотентность саммари (уникальный частичный индекс по `(session_id, recipient_email)`) и журнал приглашений
3. **Колонки в `conferences`:**
   - `summary_recipients` (nullable TEXT, CHECK) — переопределение рассылки ('all', 'owner', или NULL=дефолт)
   - `ics_sequence` (INT, default 0) — счётчик для VEVENT SEQUENCE
4. **Колонка в `users`:**
   - `is_blocked` (BOOLEAN, default false) — блокировка администратором

**SQL:**
```sql
CREATE TABLE instance_settings (key TEXT PRIMARY KEY, value JSONB, updated_at TIMESTAMPTZ DEFAULT now());
CREATE TABLE email_deliveries (...);
ALTER TABLE conferences ADD COLUMN summary_recipients VARCHAR(16) CHECK (...);
ALTER TABLE conferences ADD COLUMN ics_sequence INT DEFAULT 0;
ALTER TABLE users ADD COLUMN is_blocked BOOLEAN DEFAULT false;
```

### Миграция 88aa676ac140

**Таблица `session_audio_tracks` и атрибуция фраз (ADR-002):**

1. **Новая таблица `session_audio_tracks`** — аудиотреки сеанса с независимым статусом и сегментами Whisper
2. **Изменение `phrases`: `user_id` → `participant_id`** — атрибуция к участнику сеанса (FK `conference_participants.id`), чтобы работать с гостями без user_id

**Данные (в продакшене):** сохраняются через данные участников (`conference_participants`) и сеансов.

### Миграция f418dd65e7b1

**Переход на динамические конференции (ADR-001):**

1. **Таблица `conferences` → `conference_sessions`** (переименование + новые колонки)
   - Сохранены все данные
   - Добавлена `conference_id` (FK → новая таблица conferences)
   - `pipeline_status` остаётся (семантика не меняется)

2. **Новая таблица `conferences`**
   - Постоянные сущности (номер, slug, владелец, статус, recurrence)
   - Связь с `conference_sessions` (один ко многим, CASCADE удаление)

3. **Новая таблица `guest_access`**
   - Гости, представившиеся при входе
   - display_name обязателен, email опционален

4. **Колонки фраз и сообщений: `conference_id` → `session_id`**
   - `phrases.conference_id` → `phrases.session_id`
   - `chat_messages.conference_id` → `chat_messages.session_id`
   - `conference_participants.conference_id` → `conference_participants.session_id`

5. **Таблицы удалены**
   - `rooms` (комнаты больше не предустановленные)
   - `room_bookings` (бронирования заменены динамическими конференциями)
   - `booking_participants` (доступ теперь по паролю, не по списку)

6. **CHECK-ограничения в новой таблице conferences**
   - `is_closed = false OR password_hash IS NOT NULL`
   - `recurrence IS NULL OR is_pinned = true`

7. **CHECK-ограничение в conference_participants**
   - `(user_id IS NOT NULL)::int + (guest_id IS NOT NULL)::int = 1` — ровно один из user_id/guest_id

8. **Индексы**
   - `(conference_id, t_start)` на conference_sessions
   - `(pipeline_status)` на conference_sessions
   - `(session_id)` на conference_participants
