Consumer: CRM Events → Notifications (notifications-crm-events)
Назначение
Группа notifications-crm-events слушает интеграционные события crm (лиды, фиксации, сделки, бронирования,
документы, изменения состава участников сделки/лида) и для каждого создаёт набор уведомлений
(NotificationEntity) — fan-out получатель × канал — с учётом настроек получателя
(user-notification-settings.md) и шаблонов
(notification-template.md). Собственных доменных фактов consumer не
меняет (кросс-контекстной записи в crm нет); действует по схеме persist-then-send: сначала уведомление в
статусе Pending, затем отправку выполняет dispatcher.
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | notifications (проект Pin.Notifications.Background) |
| Consumer group | notifications-crm-events |
| Обрабатываемые события | 17 топиков pin.crm.*.v1 (таблица ниже) |
| Producer | crm (контракты — upstream source of truth) |
| ContractVersion | ожидается 1 по каждому топику; при несовпадении версии — warn-and-proceed |
| Эффект (событие → что меняется) | создаются NotificationEntity (Pending) для получателей события |
Топики и маппинг событие → Kind → получатели
Топик (pin.crm.*.v1) | NotificationKind | Получатели (из payload) | Urgency |
|---|---|---|---|
lead-created | LeadCreated | агент-владелец лида (+ руководитель группы) | Normal |
lead-expired | LeadExpired | агент-владелец лида | Normal |
fixation-approved | FixationApproved | агент-инициатор фиксации | Normal |
fixation-rejected | FixationRejected | агент-инициатор фиксации | Normal |
deal-created | DealCreated | полный активный состав сделки (Members — все UserId снапшота) | Normal |
deal-stage-changed | DealStageChanged | участники сделки | Normal |
deal-completed | DealCompleted | участники сделки | Normal |
deal-cancelled | DealCancelled | участники сделки | Normal |
booking-created | BookingCreated | участники сделки | Normal |
booking-confirmed | BookingConfirmed | участники сделки | Normal |
booking-paid | BookingPaid | участники сделки | Critical |
booking-cancelled | BookingCancelled | участники сделки | Normal |
booking-expired | BookingExpired | участники сделки | Critical |
document-requested | DocumentRequested | адресат запроса документа | Normal |
document-status-changed | DocumentStatusChanged | инициатор/участники сделки | Normal |
deal-members-changed | DealMemberAdded / DealMemberRemoved / DealExecutorChanged / DealResponsibleChanged | по дельте (см. ниже) | Normal |
lead-participants-changed | LeadParticipantAdded / LeadParticipantRemoved / LeadExecutorChanged / LeadResponsibleChanged | по дельте (см. ниже) | Normal |
Дом полей payload и получателей — соответствующий event-контракт crm (напр.
deal-created.md, booking-paid.md,
deal-members-changed.md,
lead-participants-changed.md).
Маппинг дельты состава участников (deal-members-changed / lead-participants-changed)
События несут и полный активный состав (ActiveMembers/ActiveParticipants), и дельту
(Members/Participants с ChangeType). Уведомления адресуются по характеру изменения:
ChangeType дельты | NotificationKind (deal / lead) | Получатели | Смысл |
|---|---|---|---|
Added | DealMemberAdded / LeadParticipantAdded | добавленный участник (UserId записи дельты) | «Вас добавили в сделку/лид» (с ролью из Role) |
Removed | DealMemberRemoved / LeadParticipantRemoved | удалённый участник (UserId записи дельты) | «Вас удалили из сделки/лида» (в т.ч. каскад увольнения агента) |
ExecutorChanged | DealExecutorChanged / LeadExecutorChanged | полный активный состав (ActiveMembers/ActiveParticipants) | «Сменился исполнитель» — новому исполнителю передано ведение, состав информируется |
ResponsibleChanged | DealResponsibleChanged / LeadResponsibleChanged | полный активный состав (ActiveMembers/ActiveParticipants) | «Сменился ответственный (куратор)» |
RoleChanged | — | — | смена роли того же участника уведомлением не сопровождается (пропуск) |
Одно событие может нести несколько записей дельты (напр. передача ведения — Removed прежнего + ExecutorChanged
нового): каждая обрабатывается независимо; итоговое множество (получатель, Kind) дедуплицируется (INV-NTF-1).
Payload уведомления — DealMembershipNotificationPayload / LeadMembershipNotificationPayload
(notification.md); ActorName (кто изменил состав) резолвится из контактной реплики
по ChangedByUserId (null — системное изменение).
Контекст события
- Из метаданных события доступны:
MessageId(Guid v7, дедупликация),CreatedAt,Version(=ContractVersion),AggregateId,AggregateType. - Actor — system-actor консюмера; tenant уведомления берётся из получателя (
INullableTenantedEntity: компания илиnull), не из tenant события. - Correlation сквозного трейса восстанавливается из метаданных события.
- Идемпотентность: consumer-level — ключ
notifications-crm:{EventType}:{MessageId}; business-level — unique(SourceMessageId, RecipientUserId, Channel, Kind)(INV-NTF-1) защищает от дублей fan-out при replay.
Входные данные / payload события
Consumer читает из каждого события: идентификаторы получателей (участники/владелец), маршрутные и display-поля
для payload уведомления (notification.md) — маппинг поле-в-поле по таблице ниже и
дому event-контракта. ФИО/контакты получателя в payload не приходят — адрес и локаль берутся снапшотом из
контактной реплики recipient-contact.md на enqueue (шаг 3 алгоритма), а не
синхронным lookup в identity/profile.
| Поле payload (типовое) | Тип | Обяз. | Как используется |
|---|---|---|---|
DealId/BookingId/LeadId/FixationId | Guid | Да | → id в payload уведомления + dedup-контекст + DeepLink |
Number/Stage/Status/ExpiresAt/Amount | скаляр/enum/Money | Зав. от события | → типизированные поля payload (ref-модели для id+name) |
состав участников (Members/ActiveMembers/ActiveParticipants — снапшот { UserId, Role }) | List | Для событий сделки/лида | полный активный состав → множество получателей fan-out (по всем UserId снапшота, после дедупликации), а не скалярные AgentUserId/InvestorUserId |
дельта состава (Members/Participants c ChangeType) | List | Для deal-members-changed/lead-participants-changed | адресация точечных Kind по ChangeType (added/removed/executor/responsible — таблица выше) |
адресат-скаляр (RecipientUserId — адресат документа) | Guid | Для document-requested | получатель точечного уведомления, не несущего состав |
TenantId/CompanyId | Guid | Да | контекст; tenant уведомления берётся из получателя |
Алгоритм
- Контекст события. Восстановить метаданные события (
MessageId, агрегат-источник, время); сверитьVersionсContractVersion = 1(несовпадение — warn-and-proceed); actor = system. Проверить consumer-идемпотентность по ключуnotifications-crm:{EventType}:{MessageId}: событие уже обработано → логskipped-duplicate, подтвердить (ack, no-op). ОпределитьKindпо типу события и снять снапшот payload (SourceMessageId = MessageId,SourceTopic). - Проверка входа. Обязательные идентификаторы непусты (
Guid.Emptyзапрещён); обязательные дляKindполя payload присутствуют. Нарушение контракта →NTF_INVALID_PAYLOAD(non-retriable → DLQ + алерт). - Резолв получателей и доставки. Собрать множество
RecipientUserIdиз полного активного состава участников события (Members/ActiveMembers/ActiveParticipants— всеUserIdснапшота), а не из скалярныхAgentUserId/InvestorUserId; для точечных Kind по дельте состава (deal-members-changed/lead-participants-changed, таблица «Маппинг дельты состава») получатель берётся из записи дельты (Added/Removed— конкретный участник) либо из полногоActiveMembers/ActiveParticipants(ExecutorChanged/ResponsibleChanged); для точечных событий без состава (document-requested) — адресат-скаляр. Множество дедуплицировать, исключить инициатора там, где самоуведомление не нужно (напр. смена статуса собственным действием; дляRemoved— сам удалённый участник получает уведомление); пустое множество → логno-recipients, зафиксировать отметку идемпотентности, ack. По каждому получателю:- определить каналы для
Kind/Urgencyпо настройкам получателя (INV-UNS-3/4: дефолтыKind+ явные opt-in/out;GlobalMuteи правилоCritical); - подобрать шаблон
(Kind, Locale, Channel)(INV-TPL-5; fallback локалиru; если тела канала нет — канал пропускается); если активного шаблона подKind/каналы нет —NTF_TEMPLATE_NOT_RESOLVED(non-retriable → DLQ + алерт); - адрес доставки и локаль — снапшот из контактной реплики
RecipientContactEntity(recipient-contact.md) поRecipientUserId(INV-RC-4/5); отсутствие реплики/точки канала не ошибка: внешний адрес остаётся пустым и догоняется адаптером/следующим апдейтом реплики (graceful); in-app адрес не требуется; - тихие часы: если получатель в окне тишины, канал interruptive и
Urgency != Critical— доставка откладывается до открытия окна (INV-NTF-4).
- определить каналы для
- Материализация. Для каждой валидной пары
(получатель, канал)создаётсяNotificationEntityв статусеPendingс отрисованнымиTitle/Body/DeepLink; дубль по ключу(SourceMessageId, RecipientUserId, Channel, Kind)(INV-NTF-1) пропускается (идемпотентно). In-app уведомление после фиксации доставляется realtime. - Согласованность. Все созданные
Notification(Pending)и отметка идемпотентности фиксируются в одном согласованном изменении; offset подтверждается только после успешной фиксации. Отправку по каналам выполняет dispatcher (persist-then-send); интеграционный фактdispatched(../events/notification-dispatched.md) эмитит dispatcher при переходеSent/терминальныйFailed, а не этот consumer. - Результат. Успех (в т.ч.
no-recipients/skipped-duplicate) → ack. Retriable-код → retry; non-retriable → DLQ + алерт.
Диаграмма
Идемпотентность и replay
- Consumer-level: ключ
notifications-crm:{EventType}:{MessageId}— повторная доставка того же события пропускается. - Business-level: unique
(SourceMessageId, RecipientUserId, Channel, Kind)(INV-NTF-1) — replay топика не создаёт дублирующих уведомлений. - Replay: да. Повторный re-read безопасен (dedup-ключ). Runbook —
notifications/runbooks/(будущий путь).
Retry и DLQ
| Сбой | Код | Retry | DLQ | Ручное действие |
|---|---|---|---|---|
| Инфраструктурный (PostgreSQL/Kafka недоступны) | — | Exponential backoff с паузой партиции | Нет | Runbook: восстановить зависимость |
| Нарушение контракта payload | NTF_INVALID_PAYLOAD | Нет | Да + алерт | Согласовать контракт с crm, replay |
Шаблон (Kind, Locale) не резолвится (нет Active) | NTF_TEMPLATE_NOT_RESOLVED | Нет | Да + алерт | Завести/опубликовать шаблон (SEED/admin), replay |
| Конкурентная гонка при dedup | — | Оптимистичный ретрай по версии | Нет | Автоматически |
Отсутствие контактной реплики/точки канала — не failure: уведомление создаётся, внешний адрес email/sms догоняется после апдейта реплики или адаптером (in-app доставляется всегда).
Кросс-сервисные контракты
- Ожидает от
crm: интеграционные события на 17 топикахpin.crm.*.v1, key = id агрегата (DealId/BookingId/LeadId/FixationId/документ),ContractVersion = 1, доставка at-least-once (публикация через outbox источника). Порядок гарантирован только внутри одного агрегата (по ключу партиции); между разными агрегатами/типами — нет. Дом полей payload — event-контрактыcrm(../../crm/events/); ФИО/контакты в payload не приходят (только id). - Предоставляет: набор
NotificationEntity(Pending) на паруполучатель × канал; вcrmничего не пишет (кросс-контекстной записи нет). Финальный исход доставки эмитит dispatcher какpin.notifications.dispatched.v1, а не consumer. - При устаревшей реплике/несовпадении версии:
ContractVersion != 1— warn-and-proceed (обрабатывает по известным полям); отсутствующее обязательное поле —NTF_INVALID_PAYLOAD(DLQ), контракт согласуется сcrm.
Пред- и постусловия
- Предусловие. Событие получено;
ContractVersionсовместима; для целевогоKind/LocaleсуществуетActive-шаблон с включённым каналом. - Постусловие (успех). Для каждой разрешённой пары
(получатель, канал)существует ровно одинNotificationEntity(Pending, INV-NTF-1); отметка идемпотентности зафиксирована в том же согласованном изменении; offset подтверждён после фиксации; in-app доставлено realtime. ДляBookingPaid/BookingExpired(Urgency = Critical) доставка идёт без учёта тихих часов иGlobalMute(INV-NTF-4, INV-UNS-4). Инициатор действия исключён там, где самоуведомление не нужно.
Acceptance criteria
- Given
booking-paidс 3 участниками иActive-шаблоном, when consumer обрабатывает, then для каждого разрешённого канала созданPending,Urgency = Criticalигнорирует тихие часы, offset подтверждён после фиксации. - Given повторная доставка того же события (тот же
MessageId), when обработка, thenskipped-duplicate, новых уведомлений нет,ack. - Given replay всего топика, when повторное чтение, then dedup
(SourceMessageId, RecipientUserId, Channel, Kind)(INV-NTF-1) не создаёт дублей. - Given событие, из которого множество получателей пусто (после дедупа/исключения инициатора), when
обработка, then
no-recipients, отметка идемпотентности зафиксирована,ack(без DLQ). - Given
deal-createdс составомMembersиз 4 участников, when обработка, then получателиDealCreatedрезолвятся по всемUserIdснапшотаMembers(полный активный состав), а не по скалярнымExecutorAgentUserId/InvestorUserId. - Given
deal-members-changedc дельтойChangeType = Added, when обработка, then создаётсяDealMemberAddedтолько для добавленного участника (UserIdзаписи дельты) с ролью изRole. - Given
lead-participants-changedc дельтойChangeType = Removed(в т.ч. каскад увольнения агента), when обработка, then создаётсяLeadParticipantRemovedдля удалённого участника (сам удалённый — получатель, из состава не исключается). - Given
deal-members-changedс передачей ведения (Removedпрежнего +ExecutorChangedнового), when обработка, thenDealExecutorChangedадресуется полному активному составу (ActiveMembers),DealMemberRemoved— прежнему исполнителю; множество(получатель, Kind)дедуплицируется (INV-NTF-1). - Given дельта
ChangeType = RoleChanged, when обработка, then уведомление не создаётся (пропуск), остальные записи дельты обрабатываются. - Given для
KindнетActive-шаблона, when обработка, thenNTF_TEMPLATE_NOT_RESOLVED→ DLQ + алерт. - Given payload без обязательного идентификатора, when обработка, then
NTF_INVALID_PAYLOAD→ DLQ + алерт. - Given отсутствие контактной реплики/точки канала для получателя, when обработка, then уведомление создано, in-app доставлено, внешний адрес email/sms догоняется после апдейта реплики (не failure).
- Given недоступность PostgreSQL/Kafka, when обработка, then backoff с паузой партиции, offset не сдвигается, после восстановления — повтор без дублей.
Наблюдаемость и безопасность
- Logs:
consumed/skipped-duplicate/no-recipients/enqueued(count по каналам) /failedс полямиMessageId,EventType,Kind, затронутыеRecipientUserId. PII (адрес/тело) — маскируется. - Metrics:
notifications_enqueue_duration_ms,notifications_enqueued_total{kind,channel},notifications_skipped_total{reason},notifications_dlq_total,notifications_consumer_lag_seconds. - Traces: span приёма события → span обработки → spans обращений к хранилищу (уведомления + контактная реплика); correlation из метаданных события.
- Alerts: лаг консюмера > 5 мин (README); DLQ > 0; всплеск
NTF_TEMPLATE_NOT_RESOLVED. - Безопасность: system-actor; уведомление несёт
TenantIdполучателя (null— инвестор); адрес/тело — PII; топикиcrmдоступны по m2m ACL Kafka.
Обратные ссылки
Автоссылки: где используется этот документ.
- API (1): DELETE /api/admin/notifications/templates/{templateId} — Архивировать шаблон
- Use Cases (1): UC-NTF-002. Событие сделки → многоканальное уведомление с учётом настроек
- События (4): Event Contract: pin.crm.deal-completed (DealCompletedEvent), Event Contract: pin.crm.deal-members-changed (DealMembersChangedEvent), Event Contract: pin.crm.lead-booking-intent-changed (LeadBookingIntentChangedEvent), Event Contract: pin.notifications.dispatched
- Консюмеры (8): Consumer: Identity Events → Notifications (notifications-identity-events), Consumer: Investor-Owner Events → Notifications (notifications-investor-owner-events), Consumer: News Events → Notifications (notifications-news-events), Consumer: Profile Events → Notifications (notifications-profile-events), Consumer: Promotion Events → Notifications (notifications-promotion-events), Consumer: Referral Events → Notifications (notifications-referral-events), Consumer: Rewards Events → Notifications (notifications-rewards-events), Consumer: Wallet Events → Notifications (notifications-wallet-events)
- index.md (1): Notifications Service
Связанные документы
- Целевой агрегат: notification.md (
Kind/payload/dedup/Urgency); настройки — user-notification-settings.md; шаблон — notification-template.md; контактная реплика (адрес/локаль) — recipient-contact.md. - Входные контракты (дом payload): crm/events/ (deal-, booking-, fixation-, lead-,
document-*, deal-members-changed.md,
lead-participants-changed.md — состав участников и
ParticipantRole); карта — README контекста. - ADR: 0052 tenancy, 0056 simplicity-first.