UC-NTF-002. Событие сделки → многоканальное уведомление с учётом настроек
Назначение
Описывает сценарий: интеграционное событие crm (напр. смена стадии сделки) приводит к созданию уведомлений
участникам сделки по разрешённым каналам с учётом их настроек и тихих часов. Связан с контекстом
notifications.
Metadata
| Поле | Значение |
|---|---|
| ID | UC-NTF-002 |
| Контекст | notifications (см. docs/06-services/notifications/) |
| Тип | основной |
| Статус | draft |
Актор
| Поле | Значение |
|---|---|
| Роль | System (consumer notifications-crm-events); получатели — Агент/Инвестор/МенеджерПИН (участники сделки) |
| Permissions / scope | system-actor (consumer); topic по m2m ACL Kafka |
| Tenant | уведомление несёт TenantId получателя (CompanyId или null) |
| Record-level | не применяется (fan-out по участникам события) |
Предусловие
- Событие
pin.crm.deal-stage-changed.v1опубликованоcrmв топик. - Есть
Active-шаблоны(DealStageChanged, Locale)для целевых каналов (SEED/admin). - У участников есть
UserNotificationSettingsEntity(или применяются дефолты).
Триггер
| Поле | Значение |
|---|---|
| Источник | Kafka event |
| Триггерящий элемент | consumer notifications-crm-events (crm-events.md) |
Основной Поток
crmменяет стадию сделки и публикуетpin.crm.deal-stage-changed.v1(через outbox).- Consumer
notifications-crm-eventsпринимает событие, восстанавливает контекст, сверяетContractVersionи проверяет идемпотентность по идентификатору сообщения. - Событие маппится в
Kind = DealStageChanged; собираются получатели (участники сделки) и типизированный снапшотDealNotificationPayload. - По каждому получателю: определяются разрешённые каналы по виду и срочности (дефолты + явные opt-in/out,
GlobalMute), резолвится шаблон(Kind, locale, channel), локаль и адрес — снапшотом из контактной репликиRecipientContactEntity(мягкая деградация при отсутствии точки); при попадании в тихие часы interruptive-каналам выставляетсяScheduledAt. - По каждой валидной паре (получатель × канал) создаётся
NotificationEntityв статусеPending, рендерятсяRenderedTitle/Body/DeepLink; повтор события не создаёт дубль (dedup поSourceMessageId, INV-NTF-1). - Уведомления и отметка идемпотентности сохраняются в одном согласованном изменении (persist-then-send); ack.
- Dispatcher берёт
Pending-уведомления (у которыхScheduledAtпуст/наступил) и отправляет через provider-адаптеры; in-app — realtime push (SignalR); успех →Sent(+ событиеdispatched). - Клиент получателя показывает in-app уведомление (колокольчик +1) realtime; email/push/… доставляются провайдерами (при включённых каналах).
Альтернативные Потоки
A1. Часть каналов отключена / тихие часы
- На шаге 4 у получателя
pushв opt-out, аsmsпопадает в тихие часы. pushне создаётся;smsсоздаётся сScheduledAt= открытие окна; in-app/email создаются сразу.
Возвращается к основному потоку на шаг 6.
A2. Дубль события (replay)
- На шаге 5 запись
(SourceMessageId, Recipient, Channel, Kind)уже существует (INV-NTF-1). - Consumer пропускает дубль (
skipped-duplicate), ack — новые уведомления не создаются.
A3. Нет шаблона канала
- На шаге 4 нет
Active-шаблона(Kind, Locale)для канала → канал пропускается (INV-TPL-5); при отсутствии шаблона для всех каналов —NTF_TEMPLATE_NOT_RESOLVED→ DLQ + алерт (нужна настройка шаблона).
A4. Все каналы получателя отключены
- На шаге 4 у получателя выключены все каналы для
Kind(не-Critical) или включёнGlobalMuteпри не-Criticalвиде → множество разрешённых каналов пусто. - Для этого получателя
NotificationEntityне создаётся; consumer не считает это ошибкой (ack). Если пусты каналы у всех получателей — событие обработано (no-recipients/пустой fan-out), ack без DLQ.
A5. GlobalMute и Critical-вид
- Событие соответствует
Critical-виду (напр.BookingPaid,BookingExpired). Даже приGlobalMuteи в тихие часыCriticalдоставляется без дефера (INV-UNS-4/INV-NTF-4):ScheduledAtне выставляется.
A6. Неизвестная версия контракта события
- На шаге 2
ContractVersionсобытияcrmне совпадает с ожидаемой (1) → warn-and-proceed: consumer логирует расхождение и обрабатывает известные поля; отсутствие обязательного дляKindполя → шаг 3 даётNTF_INVALID_PAYLOAD(DLQ + алерт).
A7. Адрес получателя недоступен (отставание контактной реплики)
- На шаге 4 контактная реплика
RecipientContactEntity(recipient-contact.md) ещё не содержит точки канала (событие домена опередилоusers/persons-upsert) → адрес остаётся пустым; это не ошибка:NotificationEntityсоздаётся (in-app адрес не требуется), а email/sms-адрес догоняется после апдейта реплики/адаптером на отправке. Пользователь в промежутке видит in-app-уведомление, внешний канал приходит позже.
A8. Повторные/внеочередные события по одной сделке
- По одной сделке приходят несколько событий (создание, смены стадии, завершение) — каждое даёт свой
NotificationEntity(append-семантика, состояние прежних уведомлений не перезаписывается). Порядок доставки на согласованность не влияет; дубль конкретного события отсекается INV-NTF-1.
Постусловие
- Созданы
NotificationEntityучастникам по разрешённым каналам; in-app доставлено realtime; внешние — в очереди/Sent. - Идемпотентность гарантирует отсутствие дублей при повторной доставке события.
- Offset события подтверждается только после согласованной фиксации уведомлений + отметки идемпотентности; при сбое до фиксации — событие переобрабатывается (dedup исключает дубли).
Кросс-сервисные ожидания и гарантии
| Направление | Контракт |
|---|---|
Ожидает от crm (входное событие) | pin.crm.deal-stage-changed.v1 (и смежные), ContractVersion = 1, ключ партиционирования = агрегат сделки (порядок в пределах сделки), at-least-once доставка; поля payload и участники — crm/events/ |
Ожидает от identity/profile | событийную реплику контактов (RecipientContactEntity из pin.identity.users.v1 — ФИО/телефон/статус; email/локаль — forward из контактного события profile); адрес/локаль — снапшот с реплики, синхронного lookup нет; отставание реплики — деградация, не ошибка |
| Ожидает от шаблонов | Active-шаблон (Kind, Locale, Channel) (notification-template.md); нет для всех каналов → NTF_TEMPLATE_NOT_RESOLVED |
| Ожидает от настроек | резолв каналов по UserNotificationSettings (INV-UNS-3/4/5) |
| Предоставляет | набор NotificationEntity(Pending) (fan-out получатель×канал); in-app realtime; на финальном исходе доставки — NotificationDispatchedEvent (pin.notifications.dispatched.v1, ключ NotificationId) |
| Окно eventual-consistency | in-app — доли секунды (realtime); внешние каналы — до dispatcher/retry-цикла; промежуточно пользователь видит только in-app |
Возможные Ошибки
| Код | HTTP | Условие | Поведение клиента |
|---|---|---|---|
NTF_INVALID_PAYLOAD | — (DLQ) | нарушен контракт события crm | согласовать контракт, replay |
NTF_TEMPLATE_NOT_RESOLVED | — (DLQ) | нет Active-шаблона | завести/опубликовать шаблон, replay |
Доменные События
| Событие | Когда поднимается | Subscribers |
|---|---|---|
NotificationCreated | при создании NotificationEntity | in-app realtime push (SignalR), счётчик непрочитанных |
NotificationStatusChanged | при Sent/Failed/Read | обновление бейджа/статуса |
Kafka Интеграционные События
| Событие | Topic | Когда публикуется | Consumers |
|---|---|---|---|
NotificationDispatchedEvent | pin.notifications.dispatched.v1 | при Sent/терминальном Failed (dispatcher) | (будущая аналитика; v1 без прод-потребителей) |
Acceptance Criteria
| # | Критерий | Проверка |
|---|---|---|
| AC1 | Каждый участник получает уведомление только по разрешённым каналам | событие → набор NotificationEntity = каналы, разрешённые настройками получателя |
| AC2 | Повторная доставка события не создаёт дублей | replay MessageId → нет новых записей (INV-NTF-1) |
| AC3 | In-app доставляется realtime, статус переходит в Sent | подписка SignalR получает push; Status = Sent |
| AC4 | Interruptive-канал в тихие часы деферится | ScheduledAt выставлен, отправка после окна |
| AC5 | Все каналы отключены → уведомление не создаётся, событие подтверждено | opt-out всех каналов не-Critical → нет NotificationEntity, ack, без DLQ |
| AC6 | Critical обходит GlobalMute и тихие часы | BookingPaid при GlobalMute в окне тишины → уведомление создано без ScheduledAt |
| AC7 | Отставание контактной реплики не блокирует создание | нет точки канала в RecipientContactEntity → NotificationEntity создан; in-app доставлен; внешний адрес догнан после апдейта реплики/на retry |
| AC8 | Нарушение контракта payload → DLQ | обязательное поле Kind отсутствует → NTF_INVALID_PAYLOAD, DLQ + алерт (не бесконечный retry) |
Обратные ссылки
Автоссылки: где используется этот документ.
- Use Cases (1): UC-NTF-003. Сбой провайдера → повтор доставки
- registries (1): Реестр use cases и user stories PIN
Связанные Документы
- Consumer: crm-events.md; внутренний enqueue — enqueue-notification.
- Domain entity: notification.md, user-notification-settings.md, notification-template.md.
- Событие-исход: notification-dispatched.md; realtime — in-app.md; контекст — README.