# 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, не через списки приложения.
