162 lines
13 KiB
Markdown
162 lines
13 KiB
Markdown
# 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
|