Перейти к основному содержимому

UC-MSG-008. Добавленный участник появляется в чате сделки/лида

Назначение

Системный (событийный) сценарий: когда в CRM к сделке или лиду подключают нового участника — ассистента, юриста сопровождения, со-агента или со-инвестора (созаёмщика) — он должен появиться в групповом чате процесса со своей ролью, получить доступ ко всей истории переписки и увидеть чат у себя (in-app уведомление). Состав чата — проекция ролевого состава CRM: messenger не заводит участников сам, а приводит ростер чата к активному составу события изменения участников (единая модель участников, ADR-0071). Роль переносится в чат 1:1 по значению; доступ к чату и его истории даёт членство (не время вступления), поэтому добавленный сразу видит переписку, которая шла до него. Связан с контекстом messenger. Сценарий одинаков для чата сделки (Kind = Deal, консюмер deal-members-changed) и чата лида (Kind = Lead, консюмер lead-participants) — различаются только источник события и связка (DealId/LeadId).

Metadata

ПолеЗначение
IDUC-MSG-008
Контекстmessenger
Типосновной (+ A1 реактивация ранее удалённого, A2 добавленный офлайн, A3 неизвестная роль → Guest, A4 чат ещё не провизионирован, A5 устаревшая/повторная доставка, A6 отставшая реплика имени)
Статусdraft

Актор

ПолеЗначение
РольSystem (Kafka-консюмер синка состава: deal-members-changed для чата сделки / lead-participants-changed для чата лида); бенефициар — добавленный участник (ассистент / юрист / со-агент / со-инвестор), получающий доступ к чату
Permissions / scopesystem-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.

  1. Приём события. Консюмер получает событие изменения состава; бизнес-идемпотентность — по MessageId; повтор → пропуск как дубль.
  2. Поиск чата. По DealId/LeadId находится групповой чат процесса вместе с активными участниками. Чата ещё нет → A4 (ретрай с backoff, консюмер чат не создаёт).
  3. Version-guard. Устаревшая/повторная доставка (Version не больше уже применённой к чату) → пропуск без изменений (A5). Иначе — применяем.
  4. Set-реконсиляция состава. Целевое множество — активные (UserId, роль) из снапшота события (роль переносится 1:1: Assistant→Assistant, Lawyer→Lawyer, CoAgent→CoAgent, InvestorSecondary→InvestorSecondary и т.д.). Сравнение с текущими активными участниками даёт нового участника в множестве «добавить». Неизвестная роль — сохраняется строкой + отображается как Guest (A3), реконсиляция не ломается.
  5. Добавление участника. По новому (ChatId, UserId) создаётся активная строка членства (RemovedAt = null, JoinedAt = now, роль из события); повтор на уже активную пару — no-op, на ранее удалённую — реактивация той же строки (A1, INV-PART-1). Курсор прочитанного нового участника стартует с нуля (LastReadSeq = 0), поэтому вся накопленная переписка для него — непрочитанная и доступная.
  6. Системная лента. В ленту чата пишется факт ParticipantJoined ({ UserId, Role }, INV-CE-4) в той же транзакции — участники видят системную строку «такой-то присоединился с ролью…».
  7. Доступ к истории. Добавленный становится активным членом (INV-CHAT-4) и получает доступ ко всей истории сообщений чата — она отдаётся по членству, без отсечки по времени вступления; клиент читает историю REST-выборкой сообщений, все прежние сообщения приходят непрочитанными (unread = LastMessageSeq − 0).
  8. In-app уведомление (realtime). Готовится persist-then-push доставка ChatUpdated: новому участнику — в его личную realtime-группу user:{UserId} (чат появляется в списке «мои чаты» с бейджем непрочитанного), остальным онлайн-участникам — в группу chat:{ChatId} (обновлённый ростер/шапка). Доставка — best-effort надстройка над зафиксированной лентой (realtime-dispatch); офлайн — участник увидит чат при следующем открытии приложения (A2).
  9. Согласованность. Состав, применённая версия, факт ленты, строки realtime-намерений и отметка идемпотентности фиксируются в одном согласованном изменении; собственных Kafka-событий синк состава наружу не публикует. Offset подтверждается после фиксации.

Альтернативные Потоки

A1. Реактивация ранее удалённого участника

  1. Пользователь уже был участником чата и был снят (RemovedAt != null), теперь возвращён в состав. По unique-паре (ChatId, UserId) реактивируется та же строка (RemovedAt = null, JoinedAt обновлён), а не создаётся дубль (INV-PART-1). Курсор прочитанного строки сохраняется (монотонен, назад не двигается): вернувшийся видит как прочитанное то, что читал ранее, и как непрочитанное — то, что накопилось за время отсутствия. Факт ParticipantJoined пишется в ленту.

A2. Добавленный участник офлайн

  1. В момент добавления пользователь не подключён — realtime ChatUpdated в user:{UserId} не доставляется (realtime — не source of truth). Членство и история уже зафиксированы: при следующем открытии приложения чат присутствует в списке «мои чаты» с непрочитанными. Отдельного офлайн-push именно про «вас добавили» нет (синк состава Kafka-событий наружу не эмитит); офлайн-уведомления (push/email) придут штатно при первом новом сообщении в чат — через pin.messenger.message-posted.v1notifications.

A3. Неизвестная роль участника

  1. Роль в снапшоте вне известного набора ParticipantRole (additive-значение из crm) → строка роли сохраняется как есть + warning, отображается как Guest. Участник добавляется в состав штатно, доступ и история не страдают; реконсиляция сходится по активному снапшоту (fail-safe).

A4. Чат ещё не провизионирован (out-of-order)

  1. Событие изменения состава обогнало провизию чата (топики взаимно не упорядочены; deal-created/провизия Lead-чата идут отдельно) → чат по DealId/LeadId не найден → MSG_CHAT_NOT_PROVISIONED, ретрай с backoff. После провизии повтор успешен — участник добавляется идемпотентно. После исчерпания попыток — DLQ
    • алерт (runbook).

A5. Устаревшая / повторная доставка (version-guard)

  1. Version не больше уже применённой к чату (out-of-order или повтор Kafka) → skipped-stale, состав не меняется, ParticipantJoined повторно не пишется (INV-CE-4). Реконсиляция самодостаточна: даже при пропуске отдельной дельты состав сходится к последнему активному снапшоту.

A6. Отставшая реплика имени

  1. Реплика 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_PROVISIONEDretriablebackoff-ретрай до провизии; после лимита — DLQ + алерт
Пустой/невалидный активный составMSG_DEAL_MEMBERS_INVALID / MSG_LEAD_PARTICIPANTS_INVALIDnon-retriableDLQ + алерт; разбор продьюсера crm
Инфраструктурный (PostgreSQL/Kafka недоступны)retriableбесконечный backoff, событие не теряется
Конкурентная правка состава того же чатаконкурентная3 попытки (перечитать чат); проигравшая не затирает более свежее
ContractVersion mismatchwarn-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)тест отсутствующей реплики

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные Документы