Files
vidconf/docs/db/schema.md

665 lines
41 KiB
Markdown
Raw Permalink 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.
# Схема БД
## 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