Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

View 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 десятичных цифр, первая 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