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

Notifications Service

Назначение

notifications — платформенный сервис fan-out уведомлений: превращает доменные/интеграционные события многих контекстов PIN (identity/profile/crm/calendar/news/rewards/referral/investor-owner/ promotion) в адресные уведомления получателям по каналам in-app / email / sms / push / telegram / max / whatsapp / viber. Владеет: уведомлением (NotificationEntity — экземпляр доставки), шаблонами (NotificationTemplateEntity — тело по каналам + плейсхолдеры + локаль), конфигурацией каналов (Channel + ChannelConfigEntity) и настройками пользователя (UserNotificationSettingsEntity — per-Kind per-Channel opt-in/out + тихие часы). Интеграции с провайдерами — заглушки (реализация отложена), кроме in-app (реальная доставка через Talent.Common.Realtime.* — persist-then-push).

Единый паттерн доставки — persist-then-send: consumer/handler сначала персистит NotificationEntity в статусе Pending (та же транзакция + idempotency-mark по SourceMessageId), затем dispatcher/worker отправляет через INotificationChannelProvider (providers/). Платёжные/эквайринговые операции — вне контекста (владелец — crm/эквайринг; notifications только оповещает о фактах). Масштаб/простота — ADR-0056 (без Redis-кэшей и ClickHouse; retry — polling-worker).

Metadata

ПолеЗначение
Статусdraft
Owning teamPIN Platform (Comms squad)
DomainУведомления / доставка сообщений
Data classificationMixed (PII получателя: ФИО/email/телефон/адрес каналов; тексты уведомлений)
Runtime criticalitymedium (не source of truth домена; влияет на UX и осведомлённость участников воронки)

Ownership

ArtifactOwner/Rule
PostgreSQL schemanotifications
API areaspublic (мои настройки + мои in-app уведомления) / internal (m2m enqueue) / admin (CRUD шаблонов) + SignalR-hub /hubs/notifications (in-app realtime)
Owned entitiesNotificationEntity, NotificationTemplateEntity (+owned-child ChannelTemplateEntity), ChannelConfigEntity, UserNotificationSettingsEntity (+owned-child NotificationPreferenceEntity), RecipientContactEntity (реплика контактов получателя из identity/profile)
Produced eventspin.notifications.dispatched.v1 (опционально, аналитика доставки — events/notification-dispatched.md)
Consumed eventsсобытия 10 источников — карта в Events И Consumers (по одному consumer-документу на сервис-источник)
Search indexesнет (in-app уведомления читаются по RecipientUserId + пагинация; полнотекстовый поиск не требуется, ADR-0059)
Analytics factsнет в первой версии (ClickHouse не используется, ADR-0056); при необходимости — из pin.notifications.dispatched.v1

Ответственность И Границы

  • Делает: подписка на события 9 контекстов и их отображение в NotificationKind; резолв получателей (участники сделки/владелец/покупатель/реферер/автор) из payload события; ведение долгоживущей реплики контактов получателя (RecipientContactEntity) из pin.identity.users.v1 (ФИО/телефон/статус; email/локаль — forward из контактного события profile) и снятие с неё адреса/локали при enqueue (вместо синхронного lookup); применение UserNotificationSettingsEntity (per-Kind per-Channel opt-in/out + тихие часы) для выбора каналов; резолв шаблона (Kind, Channel, Locale) и рендеринг тела по типизированным плейсхолдерам; персист NotificationEntity(Pending) (идемпотентно по SourceMessageId); диспатч через provider-адаптеры (persist-then-send); in-app доставка realtime (SignalR) + REST-список/mark-read; retry Failed фоновым worker-ом; управление шаблонами (admin) и настройками (пользователь).
  • Не делает (границы):
    • Не владеет исходными доменными фактами — только оповещает; на события не отвечает мутацией чужих агрегатов (кросс-контекстной записи нет).
    • Не рассылает чат-сообщения — личные/групповые/сделочные чаты и их realtime — messenger (ADR-0055); notifications — вне-чатовые оповещения по каналам.
    • Не хранит бинарные вложения; уведомления несут ссылки/диплинки, не файлы.
    • Не выполняет платёжные/эквайринговые операции (out of scope) — только уведомляет о booking-paid/ начислениях/выплатах как о фактах.
    • Не источник истины справочника пользователей/контактов: контакты получателя (ФИО/телефон/статус; email/локаль — forward) — долгоживущая локальная реплика RecipientContactEntity, ведомая из событий identity (pin.identity.users.v1) и контактного события profile при появлении; source of truth остаётся за identity/profile. Push-токены/telegram-chat-id — в store provider-адаптера (заглушка).
  • Source of truth: NotificationEntity, NotificationTemplateEntity, ChannelConfigEntity, UserNotificationSettingsEntitynotifications.
  • Реплики/проекции: RecipientContactEntityдолгоживущая реплика контактных точек/локали получателя (актуально из pin.identity.users.v1 — ФИО/телефон/статус; email/локаль — forward из контактного события profile, т.к. persons.v1 PII-free), с неё enqueue снимает адрес/локаль вместо синхронного lookup; RecipientAddress VO — снапшот этой реплики на NotificationEntity на момент enqueue; RecipientUserId/CompanyId — логические id.

Зависимости

ЗависимостьТипЗачем НужнаFailure mode
PostgreSQL (notifications)dbвсе агрегаты (source of truth), outbox, idempotency, retry-очередьнедоступность = недоступность сервиса
Kafkaqueue10 консюмеров источников + (опц.) outbox dispatchedдеградация: события копятся у брокера/в outbox, доставка отстаёт (eventual)
Talent.Common.Realtime.* (SignalR)infrain-app realtime push (persist-then-push)in-app доставляется при следующем list/reconnect; статус в БД сохранён
identityservice/eventsactor/permissions (JWT) + событийная реплика контактов получателя (ФИО/телефон/статус) из pin.identity.users.v1401/503 на API; при отставании реплики внешний адрес пуст (in-app работает всегда)
profileservice/eventsверификация (pin.profile.persons.v1 — PII-free, статус верификации); доп. контакты/локаль в реплику — forward при появлении контактного события profileоповещения профиля отстают (eventual)
provider-адаптеры (sms/email/push/telegram/max/whatsapp/viber)externalдоставка по внешним каналам (заглушки, отложено)канал Failed → retry-worker; in-app не затронут
crm/calendar/news/rewards/wallet/referral/investor-owner/promotioneventsисточники событий для оповещенийлаг консюмера → уведомления запаздывают

Проекты

src/services/notifications/
Pin.Notifications.Api/ # области public / internal / admin + SignalR-hub /hubs/notifications
Pin.Notifications.Application/ # прикладной слой: операции и обработчики по фичам
Pin.Notifications.Domain/ # доменные агрегаты, enum Kind/Channel, инварианты
Pin.Notifications.Infrastructure/ # хранилище, outbox, provider-адаптеры, Realtime-producer
Pin.Notifications.Background/ # 10 консюмеров источников + dispatcher + notification-retry worker
Pin.Notifications.Migrator/ # миграции + SEED (шаблоны по Kind/Channel/Locale, ChannelConfig, permissions-каталог -> identity SEED)
Pin.Notifications.Contracts/ # контракты событий pin.notifications.*

Основные Сущности

EntityНазначениеSource Of TruthДокумент
NotificationEntityЭкземпляр доставки: получатель/канал/шаблон/payload/статус/idempotencynotificationsdomain/notification.md
NotificationTemplateEntity (+ChannelTemplateEntity)Шаблон по (Kind, Locale) с телом на каждый канал + плейсхолдерыnotificationsdomain/notification-template.md
ChannelConfigEntity (+Channel enum)Конфигурация канала (провайдер, лимиты, capability)notificationsdomain/channel.md
UserNotificationSettingsEntity (+NotificationPreferenceEntity)Настройки пользователя: per-Kind per-Channel opt-in/out + тихие часыnotificationsdomain/user-notification-settings.md
RecipientContactEntityДолгоживущая реплика контактов получателя (ФИО/телефон/статус из identity; email/локаль — forward из profile)реплика (SoT — identity/profile)domain/recipient-contact.md

ER-обзор

API

Карта областей (детальные документы — по api-method-template, один файл на операцию):

Events И Consumers

Один consumer-документ на сервис-источник (внутри — маппинг типов событий → NotificationKind). Все топики — реальные/forward pin.<svc>.<event>.v1; forward-контракты (identity инвайты/членство, promotion) создаются смежной работой (ADR-0060, ADR-0061).

TypeИсточникTopicsConsumer-документ
Consumedidentitypin.identity.users.v1, .companies.v1, .access.v1, .sign-ins.v1, .company-invitation-changed.v1, .agent-membership-changed.v1consumers/identity-events.md
Consumedprofilepin.profile.verification-requests.v1, .persons.v1consumers/profile-events.md
Consumedcrmpin.crm.lead-created/lead-expired/fixation-approved/fixation-rejected/deal-created/deal-stage-changed/deal-completed/deal-cancelled/deal-members-changed/lead-participants-changed/booking-created/booking-confirmed/booking-paid/booking-cancelled/booking-expired/document-requested/document-status-changed.v1consumers/crm-events.md
Consumedcalendarpin.calendar.event-created/event-rescheduled/event-status-changed/appointment-created/appointment-confirmed/appointment-cancelled/appointment-completed/reminder-due.v1consumers/calendar-events.md
Consumednewspin.news.post-published.v1, .comment-added.v1consumers/news-events.md
Consumedrewardspin.rewards.commission-accrual-created/commission-accrual-status-changed/commission-money-received/payout-order-status-changed/reward-rule-changed.v1, pin.rewards.referral-accruals.v1 (оба контракта топика)consumers/rewards-events.md
Consumedwalletpin.wallet.transaction-recorded.v1consumers/wallet-events.md
Consumedreferralpin.referral.bonds.v1, .lead-attachments.v1consumers/referral-events.md
Consumedinvestor-ownerpin.investor-owner.property-added.v1, .property-sold.v1, .owner-linked.v1consumers/investor-owner-events.md
Consumedpromotionpin.promotion.purchase-changed.v1consumers/promotion-events.md
ProducedNotificationDispatchedEventpin.notifications.dispatched.v1events/notification-dispatched.md

Каждый consumer идемпотентен по MessageId, маппит событие → шаблон + получатели + каналы (с учётом UserNotificationSettingsEntity), персистит NotificationEntity и диспатчит через provider-адаптер (persist-then-send).

Сквозной Поток Доставки И Согласованность

Единый путь «источник → получатель» и его гарантии — общий для всех консюмеров и internal-enqueue; отдельные документы описывают дельту источника.

  1. Приём факта. Источник публикует интеграционное событие (pin.<svc>.<event>.v1, key = id агрегата) или доверенный сервис вызывает internal-enqueue. Consumer группы notifications-<svc>-events восстанавливает метаданные (MessageId, Version, корреляцию) и маппит тип события в NotificationKind.
  2. Резолв адресатов и каналов. Из payload собирается множество RecipientUserId (участники/владелец/ адресат), дедуплицируется; по каждому получателю каналы фильтруются его UserNotificationSettingsEntity (INV-UNS-3/4: дефолты Kind + явные opt-in/out; GlobalMute оставляет только Urgency = Critical).
  3. Резолв шаблона, адреса и рендер. Локаль и адрес канала берутся снапшотом из контактной реплики RecipientContactEntity (явная Locale настроек → реплика → ru; отсутствие точки канала не блокирует — graceful, in-app всегда доступен); для (Kind, Locale, Channel) выбирается Active-шаблон (INV-TPL-5, fallback локали ru); тело рендерится из типизированного Payload. Нет Active-шаблона под Kind/каналы — NTF_TEMPLATE_NOT_RESOLVED (для консюмера — DLQ + алерт; для internal-enqueue — 422).
  4. Persist (Pending). Для каждой валидной пары (получатель, канал) создаётся NotificationEntity в Pending со снапшотом RecipientAddress из реплики; дубль по ключу (SourceMessageId, RecipientUserId, Channel, Kind) (INV-NTF-1) пропускается. Тихие часы для interruptive-канала при Urgency != Critical дают ScheduledAt = открытие окна (INV-NTF-4).
  5. Commit-then-ack/send. Уведомления и отметка идемпотентности фиксируются в одном согласованном изменении; только после этого consumer подтверждает offset. Отправку выполняет dispatcher (Pending без/с наступившим ScheduledAt), повтор Failednotification-retry.
  6. Исход. In-app доставляется realtime сразу после commit (при обрыве — догоняется на list/reconnect); внешний канал по успеху → Sent, по сбою → Failed (retry по NextAttemptAt, порог MaxAttempts, INV-NTF-7). Финальный исход (Sent/терминальный Failed) опционально эмитится как pin.notifications.dispatched.v1.

Гарантии. Идемпотентность — двухслойная: consumer-level по MessageId (повторная доставка события — no-op) и business-level по dedup-ключу (INV-NTF-1: replay топика не создаёт дублей fan-out). Порядок между разными Kind/событиями одного получателя не гарантируется; per-агрегат порядок — за Kafka-ключом источника. Окна eventual-consistency: лаг консюмера (норма < 1 мин) до появления Pending; persist→send для внешних каналов (p95 < 2 с in-app, внешние — по доступности провайдера, сейчас заглушки). Промежуточное состояние: пока dispatcher не отправил — уведомление есть в БД (Pending), но во внешнем канале ещё не видно; in-app появляется в ленте сразу. Предусловие потока: для целевого Kind/Locale есть Active-шаблон и включённый канал; иначе fan-out по этому каналу пуст. Постусловие: для каждого разрешённого (получатель, канал) существует ровно один NotificationEntity на исходное событие; offset источника подтверждён; при финальном исходе — учтён в метриках доставляемости.

Runtime И Эксплуатация

ОбластьТребование
Health checks/health/live, /health/ready (PostgreSQL + Kafka), /health/startup (миграции применены + SEED шаблонов)
SLO/SLAenqueue из события p95 < 1 с; in-app доставка (persist→push) p95 < 2 с; свежесть консюмеров < 1 мин; доля Failed после исчерпания retry < 0.5%
Background jobsdispatcher (отправка Pending через адаптеры) и notification-retry (повтор Failed, polling, ADR-0056)
DashboardGrafana pin-notifications (лаг консюмеров, доля Failed/Sent, retry backlog)
Alertsлаг любого консюмера > 5 мин; outbox/retry backlog > 1000; всплеск Failed/DLQ; провайдер недоступен
Runbooksdocs/06-services/notifications/runbooks/ (будущий путь по конвенции)
Feature flagsвнешние каналы (sms/email/push/telegram/max/whatsapp/viber) — per-канал флаг включения (по умолчанию off — заглушки)

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

  • Actor types: получатель (любой авторизованный пользователь — инвестор/агент/менеджер, TenantId компании или null для инвестора, ADR-0052); сервис-источник (m2m enqueue); администратор (шаблоны, ADR-0053); система (консюмеры/worker/dispatcher).
  • Permission model: Scoped-RBAC (identity/domain/permission.md). Каталог permissions (SEED в identity): notifications.settings.manage (own), notifications.inbox.read (own), notifications.enqueue (m2m, platform-scope), notifications.templates.view/ notifications.templates.manage (admin). Public-операции — own action по RecipientUserId = actor.
  • Tenant isolation: NotificationEntity/UserNotificationSettingsEntityINullableTenantedEntity (TenantId = компания получателя или null для инвестора); шаблоны/ChannelConfig — платформенный каталог (не tenanted). Кросс-tenant чтение запрещено — доступ по RecipientUserId.
  • Sensitive/PII: адрес доставки (email/телефон/push-токен/telegram-id) и тело уведомления — PII/Sensitive; в логах маскируются (+7***1234, a***@x.ru), токены/секреты провайдеров исключены (redaction).
  • Audit: IAuditableEntity + Version на агрегатах; NotificationEntity — история статусов (SentAt/FailedAt/ReadAt + Attempts/LastError); шаблоны — версионируются.

Известные Ограничения И Assumptions

  • Провайдеры sms/email/push/telegram/max/whatsapp/viber — заглушки (INotificationChannelProvider, реализация отложена); в первой версии реально работает только in-app (SignalR). Внешние каналы включаются per-канал feature-флагом по мере интеграции.
  • Адрес доставки и локаль берутся из долгоживущей контактной реплики RecipientContactEntity (ведётся из pin.identity.users.v1 — ФИО/телефон/статус; email/локаль — forward из контактного события profile, т.к. persons.v1 PII-free), а не синхронным lookup на enqueue; при отставании реплики внешний адрес пуст и догоняется (in-app доступен всегда). Push-токены/мессенджер-id — в store соответствующего адаптера.
  • ClickHouse/facts не используются (объём мал при 1–10k активных, ADR-0056); аналитика доставки — из pin.notifications.dispatched.v1 при появлении потребителя.
  • Дайджесты/агрегация («N новых событий») — отложены; первая версия шлёт per-event уведомления с учётом тихих часов (дефер interruptive-каналов до окна).

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