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