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 team | PIN Platform (Comms squad) |
| Domain | Уведомления / доставка сообщений |
| Data classification | Mixed (PII получателя: ФИО/email/телефон/адрес каналов; тексты уведомлений) |
| Runtime criticality | medium (не source of truth домена; влияет на UX и осведомлённость участников воронки) |
Ownership
| Artifact | Owner/Rule |
|---|---|
| PostgreSQL schema | notifications |
| API areas | public (мои настройки + мои in-app уведомления) / internal (m2m enqueue) / admin (CRUD шаблонов) + SignalR-hub /hubs/notifications (in-app realtime) |
| Owned entities | NotificationEntity, NotificationTemplateEntity (+owned-child ChannelTemplateEntity), ChannelConfigEntity, UserNotificationSettingsEntity (+owned-child NotificationPreferenceEntity), RecipientContactEntity (реплика контактов получателя из identity/profile) |
| Produced events | pin.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; retryFailedфоновым 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,UserNotificationSettingsEntity—notifications. - Реплики/проекции:
RecipientContactEntity— долгоживущая реплика контактных точек/локали получателя (актуально изpin.identity.users.v1— ФИО/телефон/статус; email/локаль — forward из контактного событияprofile, т.к.persons.v1PII-free), с неё enqueue снимает адрес/локаль вместо синхронного lookup;RecipientAddressVO — снапшот этой реплики наNotificationEntityна момент enqueue;RecipientUserId/CompanyId— логические id.
Зависимости
| Зависимость | Тип | Зачем Нужна | Failure mode |
|---|---|---|---|
PostgreSQL (notifications) | db | все агрегаты (source of truth), outbox, idempotency, retry-очередь | недоступность = недоступность сервиса |
| Kafka | queue | 10 консюмеров источников + (опц.) outbox dispatched | деградация: события копятся у брокера/в outbox, доставка отстаёт (eventual) |
Talent.Common.Realtime.* (SignalR) | infra | in-app realtime push (persist-then-push) | in-app доставляется при следующем list/reconnect; статус в БД сохранён |
identity | service/events | actor/permissions (JWT) + событийная реплика контактов получателя (ФИО/телефон/статус) из pin.identity.users.v1 | 401/503 на API; при отставании реплики внешний адрес пуст (in-app работает всегда) |
profile | service/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/promotion | events | источники событий для оповещений | лаг консюмера → уведомления запаздывают |
Проекты
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/статус/idempotency | notifications | domain/notification.md |
NotificationTemplateEntity (+ChannelTemplateEntity) | Шаблон по (Kind, Locale) с телом на каждый канал + плейсхолдеры | notifications | domain/notification-template.md |
ChannelConfigEntity (+Channel enum) | Конфигурация канала (провайдер, лимиты, capability) | notifications | domain/channel.md |
UserNotificationSettingsEntity (+NotificationPreferenceEntity) | Настройки пользователя: per-Kind per-Channel opt-in/out + тихие часы | notifications | domain/user-notification-settings.md |
RecipientContactEntity | Долгоживущая реплика контактов получателя (ФИО/телефон/статус из identity; email/локаль — forward из profile) | реплика (SoT — identity/profile) | domain/recipient-contact.md |
ER-обзор
API
Карта областей (детальные документы — по api-method-template, один файл на операцию):
- Public (
/api/public/notifications/...): мои настройки — get-my-notification-settings, update-my-notification-settings; мои in-app уведомления — list-my-notifications, mark-notification-read. - Internal (m2m) (
/api/internal/notifications/...): enqueue-notification — прямой enqueue для сервисов без Kafka-события. - Admin (
/api/admin/notifications/...): CRUD шаблонов — create-template, update-template, list-templates, archive-template.
Events И Consumers
Один consumer-документ на сервис-источник (внутри — маппинг типов событий → NotificationKind).
Все топики — реальные/forward pin.<svc>.<event>.v1; forward-контракты (identity инвайты/членство,
promotion) создаются смежной работой (ADR-0060,
ADR-0061).
| Type | Источник | Topics | Consumer-документ |
|---|---|---|---|
| Consumed | identity | pin.identity.users.v1, .companies.v1, .access.v1, .sign-ins.v1, .company-invitation-changed.v1, .agent-membership-changed.v1 | consumers/identity-events.md |
| Consumed | profile | pin.profile.verification-requests.v1, .persons.v1 | consumers/profile-events.md |
| Consumed | crm | pin.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.v1 | consumers/crm-events.md |
| Consumed | calendar | pin.calendar.event-created/event-rescheduled/event-status-changed/appointment-created/appointment-confirmed/appointment-cancelled/appointment-completed/reminder-due.v1 | consumers/calendar-events.md |
| Consumed | news | pin.news.post-published.v1, .comment-added.v1 | consumers/news-events.md |
| Consumed | rewards | pin.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 |
| Consumed | wallet | pin.wallet.transaction-recorded.v1 | consumers/wallet-events.md |
| Consumed | referral | pin.referral.bonds.v1, .lead-attachments.v1 | consumers/referral-events.md |
| Consumed | investor-owner | pin.investor-owner.property-added.v1, .property-sold.v1, .owner-linked.v1 | consumers/investor-owner-events.md |
| Consumed | promotion | pin.promotion.purchase-changed.v1 | consumers/promotion-events.md |
| Produced | NotificationDispatchedEvent | pin.notifications.dispatched.v1 | events/notification-dispatched.md |
Каждый consumer идемпотентен по MessageId, маппит событие → шаблон + получатели + каналы (с учётом
UserNotificationSettingsEntity), персистит NotificationEntity и диспатчит через provider-адаптер
(persist-then-send).
Сквозной Поток Доставки И Согласованность
Единый путь «источник → получатель» и его гарантии — общий для всех консюмеров и internal-enqueue; отдельные документы описывают дельту источника.
- Приём факта. Источник публикует интеграционное событие (
pin.<svc>.<event>.v1, key = id агрегата) или доверенный сервис вызывает internal-enqueue. Consumer группыnotifications-<svc>-eventsвосстанавливает метаданные (MessageId,Version, корреляцию) и маппит тип события вNotificationKind. - Резолв адресатов и каналов. Из payload собирается множество
RecipientUserId(участники/владелец/ адресат), дедуплицируется; по каждому получателю каналы фильтруются егоUserNotificationSettingsEntity(INV-UNS-3/4: дефолтыKind+ явные opt-in/out;GlobalMuteоставляет толькоUrgency = Critical). - Резолв шаблона, адреса и рендер. Локаль и адрес канала берутся снапшотом из контактной реплики
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). - Persist (Pending). Для каждой валидной пары
(получатель, канал)создаётсяNotificationEntityвPendingсо снапшотомRecipientAddressиз реплики; дубль по ключу(SourceMessageId, RecipientUserId, Channel, Kind)(INV-NTF-1) пропускается. Тихие часы для interruptive-канала приUrgency != CriticalдаютScheduledAt= открытие окна (INV-NTF-4). - Commit-then-ack/send. Уведомления и отметка идемпотентности фиксируются в одном согласованном
изменении; только после этого consumer подтверждает offset. Отправку выполняет
dispatcher(Pendingбез/с наступившимScheduledAt), повторFailed— notification-retry. - Исход. 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/SLA | enqueue из события p95 < 1 с; in-app доставка (persist→push) p95 < 2 с; свежесть консюмеров < 1 мин; доля Failed после исчерпания retry < 0.5% |
| Background jobs | dispatcher (отправка Pending через адаптеры) и notification-retry (повтор Failed, polling, ADR-0056) |
| Dashboard | Grafana pin-notifications (лаг консюмеров, доля Failed/Sent, retry backlog) |
| Alerts | лаг любого консюмера > 5 мин; outbox/retry backlog > 1000; всплеск Failed/DLQ; провайдер недоступен |
| Runbooks | docs/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/UserNotificationSettingsEntity—INullableTenantedEntity(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.v1PII-free), а не синхронным lookup на enqueue; при отставании реплики внешний адрес пуст и догоняется (in-app доступен всегда). Push-токены/мессенджер-id — в store соответствующего адаптера. - ClickHouse/facts не используются (объём мал при 1–10k активных, ADR-0056); аналитика доставки — из
pin.notifications.dispatched.v1при появлении потребителя. - Дайджесты/агрегация («N новых событий») — отложены; первая версия шлёт per-event уведомления с учётом тихих часов (дефер interruptive-каналов до окна).
Связанные Документы
- Домены: domain/; консюмеры: consumers/; провайдеры: providers/; API: api/; события: events/; фон: background/; сценарии: use-cases/.
- Обзор: technical-assignment.md, service-boundaries.md; эталоны — support/README.md, messenger/README.md.
- ADR: 0052 tenancy, 0053 admin-identity, 0055 мини-мессенджер, 0056 simplicity-first, 0060 promotion, 0061 инвайты/членство.
- SDK: 11-backend-sdk; шаблон README: service-readme-template.