# Схема БД ## 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