71 lines
6.4 KiB
Markdown
71 lines
6.4 KiB
Markdown
# ADR-003. Модель приглашённых участников конференции (conference_invitees)
|
||
|
||
Статус: принято.
|
||
|
||
## Контекст
|
||
|
||
Продукту нужен состав приглашённых участников конференции: зарегистрированные
|
||
пользователи (user_id) и внешние по произвольному email. Организатор обязан
|
||
всегда быть в составе и быть неудаляемым. Уже существует таблица
|
||
`conference_participants` — это ФАКТИЧЕСКИЕ участники сеанса (кто реально был,
|
||
окна присутствия, единица атрибуции фраз, ADR-002); смешивать сущности нельзя.
|
||
|
||
## Решение
|
||
|
||
1. Новая таблица `conference_invitees` — приглашённые НА КОНФЕРЕНЦИЮ
|
||
(не на сеанс):
|
||
- `id UUID PK`, `conference_id FK conferences ON DELETE CASCADE NOT NULL`;
|
||
- `user_id FK users ON DELETE CASCADE NULL` — зарегистрированный;
|
||
- `email VARCHAR(255) NULL` — внешний (хранится в lower-case);
|
||
- `CHECK ((user_id IS NOT NULL)::int + (email IS NOT NULL)::int = 1)` —
|
||
ровно одна identity (тот же приём, что в `conference_participants`);
|
||
- частичные UNIQUE: `(conference_id, user_id) WHERE user_id IS NOT NULL`
|
||
и `(conference_id, email) WHERE email IS NOT NULL` — без дублей;
|
||
- `created_at`.
|
||
2. Организатор в таблице НЕ хранится: он выводится из `conferences.owner_id`
|
||
и всегда добавляется в состав на уровне API/рассылки. Инвариант
|
||
«организатор всегда в составе и неудаляем» обеспечен конструктивно —
|
||
удалить его из состава невозможно в принципе, рассинхронизация при смене
|
||
владельца исключена. Попытка добавить владельца в invitees (по user_id или
|
||
его email) молча дедуплицируется на записи.
|
||
3. Состав задаётся списком целиком (PUT-семантика поля `participants` в
|
||
create/update конференции): backend вычисляет diff, отсутствие поля —
|
||
«не менять». Права на изменение состава = права на изменение конференции.
|
||
4. Связь с фактическими участниками сеанса — аналитическая, по join без FK:
|
||
зарегистрированный — `conference_participants.user_id = invitees.user_id`;
|
||
внешний — `lower(guest_access.email) = invitees.email` (если приглашённый
|
||
вошёл гостем и указал тот же email). FK не вводим: гость может войти
|
||
с другим email или не войти вовсе — жёсткая связь ложна по природе данных.
|
||
5. Рассылка приглашений (.ics METHOD:REQUEST): получатели =
|
||
организатор + invitees (email пользователя или внешний email); для
|
||
закреплённых по-прежнему добавляются участники прошлых сеансов
|
||
(`workers/tasks/invitations.py`, дедуп по lower(email)).
|
||
6. **Видимость приглашённого в списках.** Приглашённый видит конференцию в
|
||
`GET /conferences/my` и `GET /conferences/calendar` наравне с владельцем —
|
||
строка попадает в выборку, если `owner_id == user.id` ИЛИ существует
|
||
`conference_invitees` этой конференции с `user_id == user.id` ИЛИ с
|
||
`lower(email) == lower(email пользователя)` (внешнее приглашение на адрес,
|
||
под которым человек впоследствии зарегистрировался). Критерии показа
|
||
(закреплённая — безусловно; разовая — `status=scheduled` и `scheduled_at`
|
||
в будущем) не меняются, только круг «чей» конференция. `GET /conferences/{id}`
|
||
аналогично открыт приглашённому (иначе ховер-карточка/детальная страница
|
||
получали бы 403); `is_owner` в ответе для приглашённого — `false`,
|
||
`organizer_name` — имя фактического владельца. Права на PATCH/DELETE это
|
||
расширение НЕ затрагивает — по-прежнему только владелец/администратор.
|
||
|
||
## Последствия
|
||
|
||
- (+) Чистое разделение «приглашён» / «фактически был»; пайплайн атрибуции
|
||
фраз (ADR-002) не затронут.
|
||
- (+) Инвариант организатора не требует триггеров и проверок целостности.
|
||
- (−) Внешний приглашённый не связывается с гостевым входом надёжно (только
|
||
эвристика по email) — принято как ограничение модели.
|
||
- (−) Списки состава в ответах API требуют дозагрузки (`selectinload`) —
|
||
следить за N+1 в `/my` и `/calendar`; показ приглашённому (п. 6) добавляет
|
||
туда же `EXISTS`-подзапрос по `conference_invitees` и точечный запрос имени
|
||
реального владельца на каждую НЕ свою строку списка — список короткий
|
||
(закреплённые + предстоящие), нагрузка признана приемлемой.
|
||
- Календарь и «Мои конференции» — выборка «владелец ИЛИ приглашённый» (п. 6);
|
||
до 2026-07-20 показывались только конференции владельца — приглашённый
|
||
видел состав лишь через уведомление/.ics, не через списки приложения.
|