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

162 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 десятичных цифр, первая 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