# 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_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_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