POST /api/messenger/conferences/{conferenceId}/invite — Пригласить участников в конференцию
Активный участник идущей конференции адресно зовёт конкретных членов Deal-чата. Для каждого приглашённого
заводится/реактивируется строка ростера (call-participant.md) в Invited,
и лично ему (user:{UserId}) уходит ConferenceInvite с дескриптором входа в комнату. Дом полей, статусы и
инварианты — conference-session.md.
Назначение
Дать участнику встречи по сделке позвать в идущую конференцию конкретных коллег из того же Deal-чата (адресный
«дозвон»), не поднимая новую сессию. На выходе — сводка приглашения: кого позвали и кого пропустили (уже в
комнате / уже приглашён / не участник чата). Отличается от стартового ConferenceInvite: старт рассылает
приглашение broadcast в группу chat:{ChatId}, этот метод шлёт адресный ring в user:{UserId} каждому
цели. Owning-сервис — messenger, feature group conferences, сценарий «групповой звонок по сделке».
| Поле | Значение |
|---|
| Сервис/контекст | messenger |
| API area | conferences |
| Feature group | conferences |
| Статус | draft |
| Документ | docs/06-services/messenger/api/conferences/invite-participant.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User — активный участник Deal-чата конференции |
| Auth policy / Permissions | permission messenger.conferences.use |
| Scope (RBAC) | не применяется — доступ по членству в чате |
| Record-level | активный ParticipantEntity чата ChatId конференции (INV-CHAT-4); каждая цель — тоже активный участник этого чата |
| Tenant isolation | не применяется (кросс-tenant); срез — по членству в Deal-чате |
| Audit | create/update ростера: строки целей → Invited, метка InvitedAt |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/messenger/conferences/{conferenceId}/invite |
| Success status | 200 (OK) |
| Idempotency header | не применяется — повтор идемпотентен по строке ростера: цель уже Invited/Joined → в Skipped, дубля не создаётся (INV-CPART-1) |
| Correlation | сквозной trace через traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
ConferenceId | route | Guid | Да | != Empty; существующая конференция в Active (шаги 3–4) | — |
ActorUserId | identity claims | Guid | Да | из JWT; активный участник Deal-чата конференции | — |
UserIds | body | Guid[] | Да | 1..N непустых уникальных Guid; пустой список / все Empty → ошибка валидации | — |
UserIds — цели приглашения (члены того же Deal-чата). Дубли в списке схлопываются; порядок ответа не гарантируется.
Модель ответа InviteResultModel
Сводка адресного приглашения: кого позвали и кого пропустили с причиной. Никогда не «сырой» массив —
всегда объект-обёртка (расширяемость без breaking).
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
ConferenceId | Guid | Да | ConferenceSessionEntity.Id | — | Конференция, в которую звали. |
Invited | Guid[] | Да | ростер | — | Цели, для которых заведена/реактивирована строка Invited и отправлен адресный ConferenceInvite. |
Skipped | InviteSkippedEntry[] | Да | вычисление | — | Цели, пропущенные без приглашения, с причиной. Пустой массив — все позваны. |
Вложенная модель InviteSkippedEntry
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
UserId | Guid | Да | вход | — | Пропущенная цель. |
Reason | InviteSkipReason | Да | вычисление | — | Причина пропуска (см. enum). |
Enum InviteSkipReason
| Значение | Описание |
|---|
AlreadyJoined | Цель уже активна в комнате (State = Joined) — повторный ring не нужен. |
AlreadyInvited | Цель уже в статусе Invited — приглашение уже висит, дубля не шлём. |
NotChatMember | Цель не активный участник Deal-чата — пригласить в конференцию по сделке нельзя. |
Дескриптор входа Room (MediaJoinModel) в этот ответ не входит — он уходит адресно в ConferenceInvite
каждой приглашённой цели (токен минтится при доставке), а не инициатору приглашения.
Предусловия и постусловия
Предусловия. Актор авторизован и имеет messenger.conferences.use; флаг CONFERENCE_CALLS_ENABLED включён.
Конференция существует и Active (INV-CONF-1/2); актор — активный участник её Deal-чата (INV-CHAT-4).
Постусловия. Для каждой цели-члена чата, ещё не Invited/Joined, заведена/реактивирована строка ростера
State = Invited, ConferenceRole = Speaker, InvitedAt (INV-CPART-1); поднят ConferenceParticipantInvitedEvent.
В той же транзакции на каждую приглашённую цель записана адресная строка realtime_outbox ConferenceInvite →
после коммита realtime-dispatch пушит ConferenceInvite в user:{UserId} и минтит Room.AccessToken при
доставке. Цели уже в комнате / уже приглашённые / не члены чата — в Skipped, состояние не меняется.
ActiveParticipantCount приглашение не меняет (цель ещё не вошла). Kafka-события приглашение не публикует.
Алгоритм
- Контекст и доступ. Операцию выполняет активный участник Deal-чата (
User); ActorUserId — из JWT;
требуется право messenger.conferences.use. Конференции включены флагом CONFERENCE_CALLS_ENABLED, иначе —
MSG_CONFERENCES_DISABLED.
- Проверка входа.
ConferenceId непустой; UserIds — непустой список валидных уникальных Guid (1..N);
иначе ошибка валидации.
- Загрузка сессии. Конференция по
ConferenceId; нет → MSG_CONFERENCE_NOT_FOUND. Требуется статус
Active; иначе MSG_CONFERENCE_NOT_ACTIVE (в Provisioning/Ended/Failed звать некого).
- Доступ актора. Актор — активный участник Deal-чата конференции (INV-CHAT-4); иначе
MSG_CHAT_ACCESS_DENIED.
- Классификация целей. Загружается активный состав Deal-чата и текущий ростер конференции. Для каждой цели:
не активный участник чата →
Skipped(NotChatMember); уже Joined → Skipped(AlreadyJoined); уже Invited
→ Skipped(AlreadyInvited); иначе — приглашаемая. Если ни одна цель не приглашаема из-за членства (весь
список — не участники чата) → MSG_CONFERENCE_TARGET_NOT_MEMBER (пустой ring не имеет смысла); смешанный
список с хотя бы одной приглашаемой целью выполняется частично (не-члены попадают в Skipped).
- Приглашение. Для каждой приглашаемой цели заводится/реактивируется строка ростера (
State = Invited,
ConferenceRole = Speaker, InvitedAt, INV-CPART-1) и поднимается ConferenceParticipantInvitedEvent. В том
же согласованном изменении фиксируется намерение доставки адресного ConferenceInvite (persist-then-push):
после коммита realtime-dispatch пушит ConferenceInvite в user:{UserId} и минтит Room.AccessToken в
момент доставки. Все строки и намерения — одна транзакция (атомарно).
- Результат.
InviteResultModel с Invited (кому ушёл ring) и Skipped (кого и почему пропустили) →
HTTP 200 OK.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Integration events | none — адресное приглашение Kafka не публикует |
| Realtime push (hub) | ConferenceInvite → user:{UserId} каждой приглашённой цели (комната + токен, messenger-hub.md); адресный, а не broadcast в chat:{ChatId} (тот — при старте) |
| Токены доступа | Room.AccessToken/TURN-креды минтятся при доставке инвайта (realtime-dispatch), в realtime_outbox/БД не хранятся, не логируются |
| Cache / projections / search | ростер (ix_call_participants_active не затрагивается — Invited ещё не активен) |
| Notifications | offline push «вас зовут на встречу» — вне конференс-операции (потребитель hub/notifications) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
VALIDATION_FAILED | 400 | ConferenceId = Empty, пустой/невалидный UserIds | «Проверьте параметры приглашения» | no retry |
MSG_CONFERENCE_NOT_FOUND | 404 | конференции нет | «Конференция не найдена» | no retry |
MSG_CONFERENCE_NOT_ACTIVE | 409 | конференция не Active (Provisioning/Ended/Failed) | «Конференция не активна» | no retry (перечитать) |
MSG_CHAT_ACCESS_DENIED | 403 | актор не активный участник Deal-чата | «Нет доступа к конференции» | no retry |
MSG_CONFERENCE_TARGET_NOT_MEMBER | 409 | ни одна цель не активный участник Deal-чата (весь список — не члены) | «Приглашаемый не участник чата сделки» | no retry |
MSG_CONFERENCES_DISABLED | 503 | CONFERENCE_CALLS_ENABLED = false | «Конференции временно недоступны» | retry позже |
Acceptance Criteria
- Адресный ring (happy). Дано: актор — активный участник Deal-чата
Active-конференции, цель — тоже член
чата, ещё не в ростере. Когда POST /invite {userIds:[цель]}. Тогда 200, строка цели → Invited
(InvitedAt), Invited = [цель], Skipped = []; цель получает ConferenceInvite в user:{цель} с
MediaJoinModel (токен минтится при доставке).
- Частичный батч. Дано: список из четырёх целей — новый член, уже
Joined, уже Invited, не член чата.
Когда POST /invite. Тогда 200, Invited = [новый], Skipped = [{AlreadyJoined},{AlreadyInvited}, {NotChatMember}]; повторных/лишних ring нет.
- Идемпотентность. Дано: цель уже
Invited (или Joined). Когда актор зовёт её снова. Тогда 200, цель в
Skipped(AlreadyInvited/AlreadyJoined), дубль строки не создаётся (INV-CPART-1), лишнего push нет.
- Все цели — не члены чата. Дано: весь
UserIds — не участники Deal-чата. Когда POST /invite. Тогда
409 MSG_CONFERENCE_TARGET_NOT_MEMBER, ростер не меняется.
- Конференция не активна. Дано: конференция
Ended/Provisioning/Failed. Когда POST /invite. Тогда
409 MSG_CONFERENCE_NOT_ACTIVE.
- Не участник чата (актор). Дано: актор не активный участник Deal-чата. Когда
POST /invite. Тогда
403 MSG_CHAT_ACCESS_DENIED.
- Невалидный вход. Дано: пустой
UserIds или ConferenceId = Empty. Тогда 400 VALIDATION_FAILED.
- Флаг выключен. Дано:
CONFERENCE_CALLS_ENABLED = false. Тогда 503 MSG_CONFERENCES_DISABLED.
Совместимость и наблюдаемость
- Новые optional поля ответа — additive; смена маршрута/семантики
Skipped — breaking. Kafka-контракта нет.
- Logs:
conference invite requested/invited/skipped (ConferenceId, ActorUserId, счётчики invited/skipped;
токены — никогда в логи); metrics: messenger_conference_invite_total{result},
messenger_conference_invite_targets{outcome}, latency p95; traces — сквозной trace (traceparent); dashboard
pin-messenger.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы