# 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
