6.4 KiB
6.4 KiB
ADR-003. Модель приглашённых участников конференции (conference_invitees)
Статус: принято.
Контекст
Продукту нужен состав приглашённых участников конференции: зарегистрированные
пользователи (user_id) и внешние по произвольному email. Организатор обязан
всегда быть в составе и быть неудаляемым. Уже существует таблица
conference_participants — это ФАКТИЧЕСКИЕ участники сеанса (кто реально был,
окна присутствия, единица атрибуции фраз, ADR-002); смешивать сущности нельзя.
Решение
- Новая таблица
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.
- Организатор в таблице НЕ хранится: он выводится из
conferences.owner_idи всегда добавляется в состав на уровне API/рассылки. Инвариант «организатор всегда в составе и неудаляем» обеспечен конструктивно — удалить его из состава невозможно в принципе, рассинхронизация при смене владельца исключена. Попытка добавить владельца в invitees (по user_id или его email) молча дедуплицируется на записи. - Состав задаётся списком целиком (PUT-семантика поля
participantsв create/update конференции): backend вычисляет diff, отсутствие поля — «не менять». Права на изменение состава = права на изменение конференции. - Связь с фактическими участниками сеанса — аналитическая, по join без FK:
зарегистрированный —
conference_participants.user_id = invitees.user_id; внешний —lower(guest_access.email) = invitees.email(если приглашённый вошёл гостем и указал тот же email). FK не вводим: гость может войти с другим email или не войти вовсе — жёсткая связь ложна по природе данных. - Рассылка приглашений (.ics METHOD:REQUEST): получатели =
организатор + invitees (email пользователя или внешний email); для
закреплённых по-прежнему добавляются участники прошлых сеансов
(
workers/tasks/invitations.py, дедуп по lower(email)). - Видимость приглашённого в списках. Приглашённый видит конференцию в
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, не через списки приложения.