UC-MSG-008. Добавленный участник появляется в чате сделки/лида
Назначение
Системный (событийный) сценарий: когда в CRM к сделке или лиду подключают нового участника — ассистента,
юриста сопровождения, со-агента или со-инвестора (созаёмщика) — он должен появиться в групповом чате
процесса со своей ролью, получить доступ ко всей истории переписки и увидеть чат у себя (in-app
уведомление). Состав чата — проекция ролевого состава CRM: messenger не заводит участников сам, а
приводит ростер чата к активному составу события изменения участников (единая модель участников, ADR-0071).
Роль переносится в чат 1:1 по значению; доступ к чату и его истории даёт членство (не время
вступления), поэтому добавленный сразу видит переписку, которая шла до него. Связан с контекстом
messenger. Сценарий одинаков для чата сделки (Kind = Deal, консюмер
deal-members-changed) и чата лида (Kind = Lead, консюмер
lead-participants) — различаются только источник события и связка
(DealId/LeadId).
| Поле | Значение |
|---|
| ID | UC-MSG-008 |
| Контекст | messenger |
| Тип | основной (+ A1 реактивация ранее удалённого, A2 добавленный офлайн, A3 неизвестная роль → Guest, A4 чат ещё не провизионирован, A5 устаревшая/повторная доставка, A6 отставшая реплика имени) |
| Статус | draft |
Актор
| Поле | Значение |
|---|
| Роль | System (Kafka-консюмер синка состава: deal-members-changed для чата сделки / lead-participants-changed для чата лида); бенефициар — добавленный участник (ассистент / юрист / со-агент / со-инвестор), получающий доступ к чату |
| Permissions / scope | system-scope консюмера; доступ добавленного к чату — по членству (активный Participant, INV-CHAT-4), не по tenant |
| Tenant | не применяется — messenger кросс-тенантный, доступ по членству (ADR-0052) |
| Record-level | активный ParticipantEntity (RemovedAt is null) даёт доступ к чату и его сообщениям; состав = активные участники процесса CRM (INV-CHAT-6/7) |
Предусловие
- Групповой чат процесса уже провизионирован: чат сделки — по deal-created,
чат лида — по провизии Lead-чата (lead-participants, раздел провизии).
Если ещё нет — A4.
- В CRM изменён состав: участник добавлен в сделку/лид (
ChangeType = Added, или RoleChanged/ExecutorChanged,
вводящий нового носителя роли) — событие несёт полный активный состав (ActiveMembers/ActiveParticipants)
и монотонную Version.
- Реплика имён участников (messenger-user.md) наполняется консюмером
pin.identity.users.v1; отображаемое ФИО добавленного резолвится на чтении и от синка состава не
зависит (A6).
Триггер
| Поле | Значение |
|---|
| Источник | Kafka event |
| Триггерящий элемент | pin.crm.deal-members-changed.v1 (key = DealId) — чат сделки, или pin.crm.lead-participants-changed.v1 (key = LeadId) — чат лида; дельта содержит нового участника |
Основной Поток
Дом алгоритма/маппинга — консюмеры deal-members-changed /
lead-participants; ниже — поток на уровне use-case.
- Приём события. Консюмер получает событие изменения состава; бизнес-идемпотентность — по
MessageId;
повтор → пропуск как дубль.
- Поиск чата. По
DealId/LeadId находится групповой чат процесса вместе с активными участниками. Чата
ещё нет → A4 (ретрай с backoff, консюмер чат не создаёт).
- Version-guard. Устаревшая/повторная доставка (
Version не больше уже применённой к чату) → пропуск без
изменений (A5). Иначе — применяем.
- Set-реконсиляция состава. Целевое множество — активные
(UserId, роль) из снапшота события (роль
переносится 1:1: Assistant→Assistant, Lawyer→Lawyer, CoAgent→CoAgent, InvestorSecondary→InvestorSecondary
и т.д.). Сравнение с текущими активными участниками даёт нового участника в множестве «добавить». Неизвестная
роль — сохраняется строкой + отображается как Guest (A3), реконсиляция не ломается.
- Добавление участника. По новому
(ChatId, UserId) создаётся активная строка членства
(RemovedAt = null, JoinedAt = now, роль из события); повтор на уже активную пару — no-op, на ранее
удалённую — реактивация той же строки (A1, INV-PART-1). Курсор прочитанного нового участника стартует с
нуля (LastReadSeq = 0), поэтому вся накопленная переписка для него — непрочитанная и доступная.
- Системная лента. В ленту чата пишется факт
ParticipantJoined ({ UserId, Role }, INV-CE-4) в той же
транзакции — участники видят системную строку «такой-то присоединился с ролью…».
- Доступ к истории. Добавленный становится активным членом (INV-CHAT-4) и получает доступ ко всей
истории сообщений чата — она отдаётся по членству, без отсечки по времени вступления; клиент читает историю
REST-выборкой сообщений, все прежние сообщения приходят непрочитанными (
unread = LastMessageSeq − 0).
- In-app уведомление (realtime). Готовится persist-then-push доставка
ChatUpdated: новому участнику — в
его личную realtime-группу user:{UserId} (чат появляется в списке «мои чаты» с бейджем непрочитанного),
остальным онлайн-участникам — в группу chat:{ChatId} (обновлённый ростер/шапка). Доставка — best-effort
надстройка над зафиксированной лентой (realtime-dispatch); офлайн —
участник увидит чат при следующем открытии приложения (A2).
- Согласованность. Состав, применённая версия, факт ленты, строки realtime-намерений и отметка
идемпотентности фиксируются в одном согласованном изменении; собственных Kafka-событий синк состава наружу
не публикует. Offset подтверждается после фиксации.
Альтернативные Потоки
A1. Реактивация ранее удалённого участника
- Пользователь уже был участником чата и был снят (
RemovedAt != null), теперь возвращён в состав. По
unique-паре (ChatId, UserId) реактивируется та же строка (RemovedAt = null, JoinedAt обновлён),
а не создаётся дубль (INV-PART-1). Курсор прочитанного строки сохраняется (монотонен, назад не двигается):
вернувшийся видит как прочитанное то, что читал ранее, и как непрочитанное — то, что накопилось за время
отсутствия. Факт ParticipantJoined пишется в ленту.
A2. Добавленный участник офлайн
- В момент добавления пользователь не подключён — realtime
ChatUpdated в user:{UserId} не доставляется
(realtime — не source of truth). Членство и история уже зафиксированы: при следующем открытии приложения
чат присутствует в списке «мои чаты» с непрочитанными. Отдельного офлайн-push именно про «вас добавили»
нет (синк состава Kafka-событий наружу не эмитит); офлайн-уведомления (push/email) придут штатно при первом
новом сообщении в чат — через pin.messenger.message-posted.v1 → notifications.
A3. Неизвестная роль участника
- Роль в снапшоте вне известного набора
ParticipantRole (additive-значение из crm) → строка роли
сохраняется как есть + warning, отображается как Guest. Участник добавляется в состав штатно, доступ и
история не страдают; реконсиляция сходится по активному снапшоту (fail-safe).
A4. Чат ещё не провизионирован (out-of-order)
- Событие изменения состава обогнало провизию чата (топики взаимно не упорядочены;
deal-created/провизия
Lead-чата идут отдельно) → чат по DealId/LeadId не найден → MSG_CHAT_NOT_PROVISIONED, ретрай с
backoff. После провизии повтор успешен — участник добавляется идемпотентно. После исчерпания попыток — DLQ
A5. Устаревшая / повторная доставка (version-guard)
Version не больше уже применённой к чату (out-of-order или повтор Kafka) → skipped-stale, состав не
меняется, ParticipantJoined повторно не пишется (INV-CE-4). Реконсиляция самодостаточна: даже при пропуске
отдельной дельты состав сходится к последнему активному снапшоту.
A6. Отставшая реплика имени
- Реплика
pin.identity.users.v1 по UserId добавленного ещё не доехала → в ростере вместо ФИО
показывается безопасный placeholder; строка участника PII не хранит. Доступ и история от наличия реплики
не зависят; подпись догоняет по мере обработки реплики (окно — секунды/минуты).
Постусловие
- В групповом чате процесса добавленный — активный
Participant со своей ролью (RemovedAt is null);
активный состав чата тождествен активному составу процесса в CRM (INV-CHAT-6/7).
- В системной ленте чата присутствует факт
ParticipantJoined ({ UserId, Role }); применённая версия
состава сохранена (монотонно не убывает).
- Добавленный имеет доступ ко всей истории сообщений чата (по членству, без отсечки); накопленная переписка —
непрочитанная (
LastReadSeq = 0, кроме реактивации A1).
- Онлайн-участникам и добавленному доставлен realtime
ChatUpdated (best-effort); наружу Kafka-событий синк
состава не публикует.
Возможные Ошибки
Сценарий системный (консюмер) — пользовательских HTTP-ошибок нет. Классы ошибок консюмера
(retriable/non-retriable, DLQ) — дом deal-members-changed /
lead-participants:
| Условие | Код | Класс | Поведение |
|---|
| Чат ещё не провизионирован (A4) | MSG_CHAT_NOT_PROVISIONED | retriable | backoff-ретрай до провизии; после лимита — DLQ + алерт |
| Пустой/невалидный активный состав | MSG_DEAL_MEMBERS_INVALID / MSG_LEAD_PARTICIPANTS_INVALID | non-retriable | DLQ + алерт; разбор продьюсера crm |
| Инфраструктурный (PostgreSQL/Kafka недоступны) | — | retriable | бесконечный backoff, событие не теряется |
| Конкурентная правка состава того же чата | — | конкурентная | 3 попытки (перечитать чат); проигравшая не затирает более свежее |
| ContractVersion mismatch | — | warn-and-proceed | обработка продолжается |
Доменные События
| Событие | Когда поднимается | Subscribers |
|---|
ParticipantAdded | добавлен/реактивирован активный участник (Chat.AddParticipant) | лента ChatEventEntity типа ParticipantJoined; realtime ChatUpdated в user:{UserId} нового и chat:{ChatId} |
Kafka Интеграционные События
Синк состава наружу Kafka-событий не публикует — эффект ограничен составом чата и его лентой внутри
messenger. Офлайн-уведомления добавленному приходят не про членство, а про последующие сообщения: событие
pin.messenger.message-posted.v1 (message-posted) → notifications
(push/email), когда в чат придёт новое сообщение.
Acceptance Criteria
| # | Критерий | Проверка |
|---|
| AC1 | Добавление участника в состав сделки/лида создаёт активную строку членства с ролью 1:1, пишет ParticipantJoined, доставляет ChatUpdated онлайн-участникам и в user:{UserId} нового | интеграционный тест консюмера (шаги 5–8) |
| AC2 | Добавленный видит всю историю сообщений чата (доступ по членству, без отсечки по времени вступления); накопленная переписка непрочитана (LastReadSeq = 0) | тест доступа к истории (INV-CHAT-4) |
| AC3 | Роль переносится 1:1 (Assistant/Lawyer/CoAgent/InvestorSecondary и т.д.); неизвестная роль → Guest, участник добавлен (A3) | тест маппинга ролей |
| AC4 | Реактивация ранее удалённого — та же строка (RemovedAt = null), без дубля; курсор прочитанного сохранён (A1) | тест реактивации (INV-PART-1) |
| AC5 | Устаревшая/повторная доставка (Version ≤ применённой) → skipped-stale, состав и лента не меняются (A5) | тест идемпотентности/out-of-order |
| AC6 | Событие раньше провизии чата → MSG_CHAT_NOT_PROVISIONED, ретрай; после провизии добавление успешно (A4) | тест out-of-order провизии |
| AC7 | Офлайн-добавленный видит чат при следующем открытии (членство/история зафиксированы); отдельного офлайн-push про «вас добавили» нет, но новое сообщение уведомляет штатно (A2) | тест офлайн-доставки |
| AC8 | Отставшая реплика имени → placeholder в ростере, доступ и история работают, подпись догоняет (A6) | тест отсутствующей реплики |
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные Документы
- Консюмеры (дом алгоритма/маппинга): deal-members-changed (чат сделки),
lead-participants (чат лида); провизия чата —
deal-created.
- Domain: participant.md (роль 1:1,
LastReadSeq, INV-PART-1/3/4),
chat.md (INV-CHAT-4/6/7, доступ по членству), chat-event.md
(ParticipantJoined, INV-CE-4), messenger-user.md (реплика имён).
- Realtime и офлайн: realtime-dispatch.md (
ChatUpdated в
user:{UserId}/chat:{ChatId}), message-posted.md → notifications.
- Источник ролевого состава (crm): deal-members-changed.md,
lead-participants-changed.md,
deal.md, lead.md.
- Связанные UC: UC-MSG-002 (авто-провизия чата сделки и начальный
состав), UC-MSG-001 (личный чат — для контраста с
групповым).
- ADR: 0055 мини-мессенджер,
0071 единая модель участников,
0072 каскад увольнения агента,
0052 tenancy.