Первоначальная версия VidConf
This commit is contained in:
161
docs/architecture/adr/001-dynamic-conferences-pivot.md
Normal file
161
docs/architecture/adr/001-dynamic-conferences-pivot.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user