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

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

0
docs/db/.gitkeep Normal file
View File

664
docs/db/schema.md Normal file
View File

@@ -0,0 +1,664 @@
# Схема БД
## 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