13 KiB
ADR-001: Динамические конференции вместо бронирований комнат
Статус
ACCEPTED
Контекст
Продукту не подходит модель предустановленных переговорных комнат с
бронированием: конференция должна создаваться динамически (мгновенно из
лобби или планово из календаря). Незакреплённая умирает по завершении
(история/саммари остаются), закреплённая — постоянная, с повторениями. Вход
— по ссылке или номеру, гости допускаются после «представиться». Прежняя
схема (rooms + room_bookings с EXCLUDE-constraint, conferences как
сеанс, привязанный к room_id) этой концепции не соответствует.
Продакшен-данных на момент миграции не было — допустима структурная миграция
с переименованием таблиц.
Решения
1. Модель данных
Двухуровневая модель: конференция (пользовательская сущность) и сеанс (один запуск конференции, единица AI-пайплайна).
- Таблица
conferencesпереименовывается вconference_sessions(данные сохраняются):id,conference_idFK→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; webhookroom_started→active;room_finished→ended(если не закреплена; ставится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 десятичных цифр, первая 1–9 (
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_idFK→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 — заполнено ровно одно из двух. Webhookparticipant_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— утверждённый UIbackend/alembic/versions/f418dd65e7b1_dynamic_conferences.py— миграция реализует раздел «Модель данных»backend/api/conferences.py— резолв/join/guest-join по п. 4 и п. 6