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

Event Contract: pin.crm.deal-members-changed (DealMembersChangedEvent)

Версионируемый контракт интеграционного события «участники сделки изменены». Consumer-документы (messenger, notifications) ссылаются сюда, не переописывая payload. Фиксирует любое изменение активного состава участников сделки (DealMemberEntity): добавление участника, удаление участия, смену роли, передачу ведения (смену Executor) и переназначение куратора (смену Responsible). Дом полей агрегата — entity: DealEntity.

Назначение

Событие даёт messenger актуальный состав участников чата сделки без синхронного запроса в crm. Чат сделки создаётся по deal-created; при последующих изменениях участников (операции POST /deals/{id}/members, DELETE /deals/{id}/members/{memberId}, PUT /deals/{id}/members/{memberId}/role — README, инвариант INV-DEAL-3) messenger синхронизирует участников комнаты. Полный активный состав (ActiveMembers) позволяет применять идемпотентную set-реконсиляцию (добавить недостающих, удалить лишних), а дельта (Members c ChangeType) — точечно обновлять роли и слать уведомления.

Metadata

ПолеЗначение
Event typepin.crm.deal-members-changed
Topicpin.crm.deal-members-changed.v1
Partitions / key3 (crm — низкообъёмный, 1–10k активных сделок, ADR-0056) / key = AggregateId = DealId (per-deal порядок изменений на одной партиции)
ContractVersion1
Producercrm: добавление/удаление участника, смена роли участника сделки (deal.md)
Статусdraft
Атомарность публикации

Событие публикуется атомарно с мутацией агрегата: пока изменение состава не зафиксировано, событие не отправляется; после фиксации — доставляется гарантированно (at-least-once), ключ = DealId.

Payload DealMembersChangedEvent

Дом полей — entity: DealEntity / DealMemberEntity (раздел «Доменные и интеграционные события»); здесь перечислено только то, что реально в событии. Событие несёт и полный активный состав (для идемпотентного синка), и дельту изменения (для точечных действий и уведомлений).

ПолеТипОбязательностьИсточник (поле агрегата)CompatibilityОписание
DealIdGuidДаDealEntity.IdImmutableКлюч агрегата (= Kafka key). Чат-комната сделки в messenger.
TenantIdGuidДаDealEntity.TenantIdImmutableDeveloperCompanyId застройщика (ADR-0052).
MembersList<DealMemberChange>Да (≥ 1)дельта операцииAdditive onlyЧто изменилось в этом событии (модель ниже).
ActiveMembersList<DealMemberSnapshot>Даактивные DealMemberEntity (RemovedAt is null)Additive onlyПолный активный состав после изменения — для set-реконсиляции чата.
ChangedByUserIdGuid?НетDealMemberEntity.AddedByUserId / RemovedByUserId (актор операции)Additive onlyКто изменил состав; null — система (авто-состав при создании / backfill PinManager).
ChangedAtDateTimeOffsetДамомент мутации (AddedAt/RemovedAt/апдейт роли)Additive onlyВремя изменения состава.
VersionlongДаDealEntity.VersionImmutable semanticsДоменная версия сделки; монотонный guard порядка у потребителя.

Вложенная модель DealMemberChange

ПолеТипОбязательностьОграниченияОписание
UserIdGuidДалогическая ссылка на identity.users(id)Пользователь-участник (DealMemberEntity.UserId).
Rolestring (enum ParticipantRole)Дазначения — deal.mdРоль после изменения; для Removed — роль на момент удаления.
ChangeTypestring (enum DealMemberChangeType)ДаAdded / Removed / RoleChanged / ExecutorChanged / ResponsibleChangedХарактер изменения участия.

Вложенная модель DealMemberSnapshot

ПолеТипОбязательностьОграниченияОписание
UserIdGuidДассылка на identity.users(id)Активный участник (RemovedAt is null).
Rolestring (enum ParticipantRole)Дазначения — deal.mdТекущая роль участника.

Enum DealMemberChangeType

Enum контракта события; передаётся строкой.

ЗначениеУсловиеИсточник в агрегате
AddedДобавлен новый участникдобавление участника (AddedAt проставлен)
RemovedУчастие снятоснятие участия (RemovedAt проставлен)
RoleChangedСменилась роль существующего участникасмена роли участника
ExecutorChangedСменился текущий исполнитель (передача ведения)атомарная передача роли Executor новому участнику
ResponsibleChangedСменился/назначен ответственный (куратор ведения)назначение/смена участника с ролью Responsible
Смена носителя роли

ExecutorChanged и ResponsibleChanged описывают смену носителя соответствующей роли (передача ведения / переназначение куратора) — в отличие от RoleChanged, который меняет роль у того же участника. Дельта такой операции может нести две записи Members (снятие роли у прежнего носителя + назначение новому); полный актуальный состав всегда в ActiveMembers.

Role — enum ParticipantRole (Executor / Responsible / CoAgent / Assistant / Lawyer / InvestorPrimary / InvestorSecondary / SellManager / PinManager); дом значений — deal.md.

Инварианты состава (INV-DEAL-3)

Ровно один активный Executor; ровно один активный InvestorPrimary; InvestorSecondary — сколько угодно; Responsible опционален; не более одного PinManager; уникальность UserId среди активных; процесс нельзя оставить без активного Executor. Подробности — deal.md (INV-DEAL-3).

Без PII

Событие не содержит ФИО/телефонов: messenger резолвит отображаемые имена из собственной реплики pin.identity.users.v1 по UserId.

Когда публикуется

УсловиеПереход состоянияПубликация
Добавлен новый активный участник (INV-DEAL-3)без смены DealStatus; Version++атомарно со сменой состояния агрегата
Снято участие (RemovedAt проставлен)без смены DealStatus; Version++та же
Смена роли участникабез смены DealStatus; Version++та же
Передача ведения — новый Executor (ExecutorChanged)без смены DealStatus; Version++атомарно (снятие + назначение в одной операции; процесс не остаётся без Executor)
Назначение/смена куратора — Responsible (ResponsibleChanged)без смены DealStatus; Version++та же
Отличие от deal-created

Изменение состава не меняет DealStatus и не дублирует deal-created (начальный состав при создании сделки уходит в payload DealCreatedEvent, отдельного deal-members-changed на создание не публикуется). Одно изменение состава → одно событие с дельтой в Members и полным ActiveMembers.

Потребители (consumers)

Consumer-контекстSide effectsИдемпотентностьЗадержка (SLA)
messengerСинхронизация участников чата сделки: реконсиляция комнаты по ActiveMembers (пригласить недостающих, убрать отсутствующих), обновление ролей по Members; системное сообщение о смене составаMessageId + монотонный Version-guard на DealId< 1 мин
notificationsУведомление добавленным (Added — «вас добавили в сделку») и удалённым (Removed — «вас удалили из сделки») участникам; уведомление о передаче ведения (ExecutorChanged) и смене куратора (ResponsibleChanged) полному активному составу (ActiveMembers)MessageId (дедуп)< 1 мин

Порядок событий одной сделки гарантирован ключом DealId (одна партиция). Потребитель держит Version-guard: событие с Version не больше уже применённого — пропускается (устаревшая доставка). ActiveMembers делает обработку самодостаточной — при пропуске отдельной дельты состав всё равно сходится.

Доставка, порядок и обработка сбоев

  • Гарантия публикации. Событие уходит только после фиксации изменения состава (Version++); при откате не публикуется. Доставка — at-least-once, ключ DealId.
  • Порядок и version-guard. События одной сделки упорядочены ключом DealId (одна партиция). Потребитель держит монотонный Version-guard: событие с Version не больше уже применённого — пропускается (устаревшая доставка). При гонке двух изменений состава побеждает большее Version; более раннее, доставленное позже, отбрасывается guard-ом.
  • Самодостаточность (set-реконсиляция). Даже при пропуске отдельной дельты ActiveMembers даёт полный актуальный состав — messenger сходится к нему реконсиляцией (пригласить недостающих, убрать лишних).
  • Идемпотентность потребителя. Дедуп по MessageId + Version-guard; повторная доставка не меняет уже сошедшийся состав.
  • Retry. Транзиентный сбой потребителя → повтор обработки с backoff; событие доступно до успешной обработки.
  • DLQ. Неустранимый сбой после исчерпания попыток → dead-letter c алертом; проигрывание безопасно (реконсиляция по ActiveMembers идемпотентна).

Версионирование и совместимость

  • ContractVersion = 1; при несовпадении версии контракта потребитель логирует предупреждение и продолжает обработку. Топик кодирует версию по конвенции суффиксом .v1.
  • Additive-only для minor: новые optional-поля с безопасным default — без bump.
  • Breaking (удаление/переименование/смена типа или смысла) — новый ContractVersion = 2 + новый топик pin.crm.deal-members-changed.v2 + dual-publish на период миграции + deadline миграции consumers + sunset .v1.
Fail-safe для неизвестных значений

Unknown enum (Role / ChangeType) — fallback: сохранить строку как есть, warning; неизвестный ChangeType трактовать как no-op для дельты и опираться на ActiveMembers (состав всё равно синхронен).

Безопасность

ПолеКласс данныхПравило
DealId, TenantId, VersionInternalinclude
Members[].UserId, ActiveMembers[].UserId, ChangedByUserIdInternal (идентификатор, не PII)include
Members[].Role, Members[].ChangeType, ActiveMembers[].RoleInternalinclude
ChangedAtInternalinclude
PII и маскирование

Событие не переносит raw PII: ФИО и телефоны участников в payload отсутствуют — только UserId. Отображаемые имена/контакты messenger берёт из своей реплики pin.identity.users.v1; при логировании producer/consumer телефоны/ФИО не фигурируют (маскирование +7***1234 — правило user-upserted, здесь неприменимо, т.к. таких полей нет).

  • Доступ к топику ограничен m2m ACL Kafka (producer crm, consumers messenger, notifications).
  • Событие НЕ содержит secrets, tokens, credentials, эскроу/платёжных реквизитов, документов, вложений.

Наблюдаемость

  • Логи (без PII): факт публикации deal-members-changed с DealId/Version, ChangeType и Role дельты (UserId — идентификатор, не PII); ФИО/телефоны участников в логах не фигурируют.
  • Метрики: число изменений состава по топику; сквозная задержка синхронизации чата у messenger (< 1 мин); число срабатываний Version-guard (индикатор переупорядочения/гонок); глубина DLQ (порог алерта > 0).
  • Доступ: топик — m2m ACL Kafka (producer crm, consumers messenger, notifications); PII в событие не входит.

Acceptance Criteria

  • Given сделка с активным составом, When добавлен новый участник, Then Version++ и ровно одно deal-members-changed публикуется с дельтой (ChangeType = Added) в Members и полным ActiveMembers.
  • Given снятие участия / смена роли, When изменение фиксируется, Then событие с ChangeType = Removed/RoleChanged; DealStatus не меняется.
  • Given передача ведения, When роль Executor атомарно переходит новому участнику, Then событие с ChangeType = ExecutorChanged; в ActiveMembers ровно один активный Executor (процесс не остаётся без исполнителя).
  • Given назначение/смена куратора, When меняется носитель роли Responsible, Then событие с ChangeType = ResponsibleChanged; Responsible опционален, инвариант единственного Executor не нарушен.
  • Given событие с Version не больше уже применённого, When messenger обрабатывает, Then оно пропускается (устаревшая доставка).
  • Given пропущена отдельная дельта, When приходит следующее событие, Then состав чата всё равно сходится к ActiveMembers (set-реконсиляция).
  • Given создание сделки, When формируется начальный состав, Then deal-members-changed не публикуется (начальный состав уходит в deal-created).
  • Given неизвестный ChangeType, When messenger обрабатывает, Then дельта трактуется как no-op, а состав берётся из ActiveMembers (fail-safe) + warning.
  • Given breaking-изменение payload, When новый контракт, Then pin.crm.deal-members-changed.v2 + dual-publish + sunset .v1.

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

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

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