Files
vidconf/docs/architecture/adr/001-dynamic-conferences-pivot.md

13 KiB
Raw Permalink Blame History

ADR-001: Динамические конференции вместо бронирований комнат

Статус

ACCEPTED

Контекст

Продукту не подходит модель предустановленных переговорных комнат с бронированием: конференция должна создаваться динамически (мгновенно из лобби или планово из календаря). Незакреплённая умирает по завершении (история/саммари остаются), закреплённая — постоянная, с повторениями. Вход — по ссылке или номеру, гости допускаются после «представиться». Прежняя схема (rooms + room_bookings с EXCLUDE-constraint, conferences как сеанс, привязанный к room_id) этой концепции не соответствует. Продакшен-данных на момент миграции не было — допустима структурная миграция с переименованием таблиц.

Решения

1. Модель данных

Двухуровневая модель: конференция (пользовательская сущность) и сеанс (один запуск конференции, единица AI-пайплайна).

  • Таблица conferences переименовывается в conference_sessions (данные сохраняются): id, conference_id FK→conferences (NOT NULL, CASCADE), title (снапшот), t_start, t_end, pipeline_status, summary_data, created_at. Колонки room_id, booking_id удаляются. Статус-машина пост-обработки живёт в conference_sessions.pipeline_status (семантика не меняется).
  • Создаётся новая таблица conferences — сущность конференции: id UUID PK, number VARCHAR(9) UNIQUE NOT NULL, slug VARCHAR(22) UNIQUE NOT NULL, title VARCHAR(255) NULL, owner_id UUID NULL FK users ON DELETE SET NULL, status conference_status NOT NULL DEFAULT 'scheduled', is_pinned BOOL NOT NULL DEFAULT false, is_closed BOOL NOT NULL DEFAULT false, password_hash TEXT NULL, scheduled_at TIMESTAMPTZ NULL, duration_minutes INT NULL, recurrence JSONB NULL, ended_at TIMESTAMPTZ NULL, created_at. CHECK: is_closed = false OR password_hash IS NOT NULL; recurrence IS NULL OR is_pinned = true.
  • В phrases, chat_messages, conference_participants колонка conference_id переименовывается в session_id (FK → conference_sessions).
  • rooms, room_bookings, booking_participants удаляются. Backfill в миграции: для каждой room, на которую ссылаются сеансы, создаётся запись conferences (status='ended', slug=permanent_link, номер генерируется, owner_id=NULL), сеансы перевязываются, затем таблицы комнат/броней дропаются. Список допущенных участников закрытой брони (booking_participants) уходит без замены: доступ к закрытой конференции — только по паролю (утверждённая концепция, п. 7).

2. Жизненный цикл

conference_status = ENUM(scheduled, active, ended).

  • Статус draft отклонён: создание атомарно из формы, черновики не нужны.
  • «pinned» — не статус, а ортогональный флаг is_pinned (закреплённость не исключает ни scheduled, ни active).
  • Переходы: мгновенное создание → active (вход сразу); плановое → scheduled; webhook room_startedactive; room_finishedended (если не закреплена; ставится ended_at) или обратно scheduled (закреплена). Beat-задача переводит в ended незакреплённые scheduled, чьё время истекло без единого сеанса. ended — терминальный: join отвечает 410, строка и история не удаляются.

3. Recurrence — собственная модель, не RRULE

Хранится в conferences.recurrence (JSONB), Pydantic-схема RecurrenceRule:

type: 'weekly' | 'biweekly' | 'monthly' | 'every_n_days'
weekdays: list[int]        # 0=пн…6=вс — для weekly/biweekly
day_of_month: int (1..31)  # для monthly; 31 в коротком месяце → последний день
interval_days: int >= 1    # для every_n_days
anchor_date: date          # точка отсчёта чётности biweekly / шага every_n_days
time_local: 'HH:MM'
timezone: str              # IANA
duration_minutes: int

Обоснование: UI фиксирует ровно 4 типа повторения — структурированная модель отображается на форму 1:1, валидируется Pydantic и разворачивается чистой функцией expand_occurrences(rule, t_from, t_to) -> list[datetime UTC] (TDD); RRULE дал бы избыточную выразительность, парсинг и зависимость без выгоды. Инвариант №1 не нарушен: правило — не timestamp (локальное время + IANA-зона нужны для корректности при смене смещения), все timestamp-колонки — UTC.

4. Номер, постоянная ссылка и резолв

  • Номер: 9 десятичных цифр, первая 19 (secrets.randbelow), уникален, генерация с retry при коллизии. Энтропия: 9·10^8 вариантов ≈ 2^29.75. Оценка перебора: при ≤1000 живых конференций вероятность угадать с одной попытки ≤ 1.2·10^-6; при rate limit 10 запросов/мин на IP матожидание подбора с одного IP ≈ 60+ суток непрерывного перебора. Отображение — группами 3-3-3 («884 210 466»); в макетах номера-плейсхолдеры 7-значные — это контент, не layout, отклонение фиксируется здесь.
  • Резолв (GET /conferences/resolve, публичный, rate limit):
    • несуществующий номер/slug → 404 (единообразный, без деталей);
    • существующая завершённая (ended) → 200 с минимальным ответом {id, title, status='ended'} — пользователь по старой ссылке/номеру видит «конференция завершена», а не «не найдено»;
    • для ended НЕ раскрывается ничего сверх минимума: is_closed / requires_password не возвращаются (войти всё равно нельзя). Trade-off принят осознанно: утечка факта существования/названия завершённой конференции допустима, т.к. держатель slug (64 бита) или номера практически всегда — бывший участник, перебор закрыт энтропией и rate limit'ом, а реальный барьер повторного входа — 410 на join/guest-join (протестировано).
  • Ссылка: slug = secrets.token_urlsafe(8) — 11 символов base64url, 64 бита энтропии; URL вида /j/{slug}. Slug также служит именем LiveKit-комнаты (замена room.permanent_link). Номер и slug неизменны всё время жизни конференции и не переиспользуются.

5. Судьба инварианта №2 (EXCLUDE USING gist)

Constraint снимается — исчезает вместе с таблицей room_bookings. Конференции не конкурируют за общий ресурс: пересечения по времени у одного владельца допустимы by design, защита БД не нужна. Расширение btree_gist из БД не удаляем (безвредно, миграция проще и обратима).

6. Гости

  • Новая таблица guest_access: id UUID PK, conference_id FK→conferences (CASCADE), display_name VARCHAR(255) NOT NULL, email VARCHAR(320) NULL, created_at. Создаётся эндпоинтом гостевого join (без auth, rate limit).
  • LiveKit identity: зарегистрированный — str(user_id) (как сейчас, обратная совместимость webhook-парсера); гость — guest:{guest_access.id}, name=display_name. Email в LiveKit (metadata) не передаётся — PII не утекает другим участникам.
  • conference_participants: user_id становится NULLABLE, добавляется guest_id UUID NULL FK guest_access; CHECK — заполнено ровно одно из двух. Webhook participant_joined по префиксу identity создаёт строку участника с user_id либо guest_id.
  • Рассылка саммари: получатели сеанса = email пользователей guest_access.email IS NOT NULL участников сеанса.
  • Закрытая конференция требует пароль и от гостя.

7. Переиспользование кода бронирований / удаление

Переиспользуется: инфраструктура FullCalendar и диалогов календаря (Booking* → Conference*), механика пароля (argon2, JoinPasswordDialog, ClosedJoinPage → единый join-flow), UTC-валидаторы из schemas/bookings.py, генерация slug (token_urlsafe), webhook-пайплайн с идемпотентностью, beat-каркас release_idle_rooms (адаптируется в очистку конференций), booking-модель концептуально → плановая конференция (scheduled_at, is_closed, password_hash переезжают в conferences).

Удаляется: seed 100 комнат (backend/scripts/seed.py), модели room.py/booking.py/booking_participant.py, сервисы booking_rules.py/bookings.py/room_access.py (включая «правило часа» — не имеет смысла без конкуренции за комнаты), репозитории rooms.py/bookings.py, роутеры api/rooms.py/api/bookings.py, схемы rooms.py/bookings.py, frontend: RoomCard, lib/roomColors.ts, api/rooms.ts, api/bookings.ts, Booking*-диалоги, тесты бронирования/комнат.

Последствия

  • Плюсы: модель 1:1 соответствует продукту; исчезает класс конфликтов бронирования и его код; гости — полноценные участники пайплайна саммари; единый join-flow (ссылка/номер/пароль/гость); внятный UX по старым ссылкам («конференция завершена» вместо «не найдено»).
  • Минусы: разрушительная миграция (переименование таблиц/колонок) — допустимо до продакшена, но затрагивает пайплайн пост-обработки (пишет в conference_sessions); публичные эндпоинты resolve/guest-join требуют rate limiting (Redis) и единообразного 404 для несуществующих; резолв раскрывает существование и название завершённой конференции держателю её номера/ссылки (принятый trade-off, см. п. 4).
  • Нейтрально: btree_gist остаётся установленным без использования.

Ссылки

  • design/mockups/{lobby,join,calendar,my-conferences}.html — утверждённый UI
  • backend/alembic/versions/f418dd65e7b1_dynamic_conferences.py — миграция реализует раздел «Модель данных»
  • backend/api/conferences.py — резолв/join/guest-join по п. 4 и п. 6