Entity / Aggregate: NotificationEntity
Дом полей агрегата «Уведомление» контекста notifications (greenfield). Все проекции (карточка in-app,
список, payload dispatched, provider-модель) ссылаются сюда и описывают только дельту.
Назначение
NotificationEntity — единичный экземпляр доставки: одно (получатель × канал) для одного исходного
события. Fan-out источника (событие → несколько получателей × несколько каналов) материализуется как
набор независимых NotificationEntity — каждый со своим статусом и retry. Множество получателей fan-out
резолвится из полного активного состава участников события-источника (для событий, несущих состав —
сделки/лиды, где событие переносит снапшот ActiveMembers/ActiveParticipants/Members), а не из отдельных
скалярных ролей (AgentUserId/InvestorUserId); дом маппинга событие → получатели — consumer-документы.
Aggregate root; тело сообщения — типизированный PayloadModel (jsonb, versioned), рендерится шаблоном
(notification-template.md) под выбранный Channel (channel.md).
Идемпотентность fan-out — по SourceMessageId (MessageId исходного Kafka-события). Доставка —
persist-then-send: агрегат создаётся в Pending, отправку выполняет dispatcher/adapter.
Классификация
| Поле | Значение |
|---|---|
| Контекст | notifications |
| Kind | AggregateRoot |
| Source of truth | notifications |
| Таблица | notifications.notifications |
| Tenant isolation | TenantId = компания получателя (RecipientCompanyId) или null (инвестор вне tenant), ADR-0052 |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор уведомления. |
TenantId | Guid? | Нет | index where not null | app | Компания получателя (null — инвестор). |
RecipientUserId | Guid | Да | index ix_notifications_recipient (composite с Status) | событие/enqueue | Получатель; логическая ссылка identity.users(id). |
RecipientCompanyId | Guid? | Нет | — | событие | Компания получателя (контекст). |
Kind | NotificationKind | Да | enum (хранится как text), varchar(48), index | app | Семантический вид (см. enum). |
Channel | Channel | Да | enum (хранится как text), varchar(16) | app | Канал доставки (channel.md). |
TemplateId | Guid | Да | FK fk_notifications_template → notifications.notification_templates(id) | резолв | Шаблон (Kind, Locale), из которого рендерилось тело. |
Locale | string | Да | varchar(8), default ru | settings/user | Локаль рендера — BCP-47 из закрытого списка (дом — profile.md); НЕ LanguageId справочника handbook (ADR-0063). |
Payload | PayloadModel | Да | jsonb payload, versioned (ContractVersion) | событие | Типизированный снапшот данных (см. ниже). |
RenderedTitle | string? | Нет | varchar(256) | рендер | Заголовок (для in-app/push); null — канал без заголовка (sms). |
RenderedBody | string | Да | text | рендер | Тело после подстановки плейсхолдеров. |
DeepLink | string? | Нет | varchar(1024) | рендер | Диплинк в приложение (карточка сделки/поста/лота). |
RecipientAddress | RecipientAddress (owned) | Нет | см. VO ниже | реплика/adapter | Снапшот адреса канала на момент enqueue из контактной реплики recipient-contact.md (null — точки нет, догоняет адаптер/следующий retry). |
Status | NotificationStatus | Да | enum (хранится как text), varchar(16), index | app/dispatcher | Статус доставки (см. enum). |
SourceService | string | Да | varchar(32) | consumer | Контекст-источник (crm/news/…) — для аудита/аналитики. |
SourceTopic | string? | Нет | varchar(96) | consumer | Топик исходного события (null для m2m enqueue). |
SourceMessageId | Guid | Да | unique ux_notifications_dedup (SourceMessageId, RecipientUserId, Channel, Kind) | envelope | Идемпотентность fan-out (MessageId события). |
Urgency | NotificationUrgency | Да | enum (хранится как text), default Normal | Kind-политика | Влияет на тихие часы (Critical игнорирует quiet hours). |
ScheduledAt | DateTimeOffset? | Нет | index partial where status = 'Pending' | app/quiet-hours | Не отправлять раньше (дефер interruptive-каналов на окно). |
Attempts | int | Да | default 0, >= 0 | dispatcher | Число попыток отправки. |
NextAttemptAt | DateTimeOffset? | Нет | index partial where status = 'Failed' | retry-worker | Момент следующего повтора (backoff). |
LastError | string? | Нет | varchar(512) | adapter | Последняя ошибка провайдера (безопасное сообщение). |
ProviderMessageId | string? | Нет | varchar(128) | adapter | Внешний id доставки (delivery-receipt провайдера). |
SentAt | DateTimeOffset? | Нет | — | dispatcher | Момент перехода в Sent. |
FailedAt | DateTimeOffset? | Нет | — | dispatcher | Момент последней неудачи. |
ReadAt | DateTimeOffset? | Нет | — | получатель | In-app прочитано (mark-read). |
CreatedAt | DateTimeOffset | Да | index ix_notifications_created_at desc | app | Создание (enqueue). |
UpdatedAt | DateTimeOffset | Да | — | app | Момент последнего изменения. |
DeletedAt | DateTimeOffset? | Нет | — | retention | Soft-delete (авто-очистка). |
Enum NotificationStatus
| Значение | Описание |
|---|---|
Pending | Создано, ожидает отправки dispatcher-ом (учитывает ScheduledAt). |
Sent | Отправлено провайдером/доставлено in-app (терминальное для внешних каналов). |
Failed | Отправка не удалась; ждёт retry (NextAttemptAt) или исчерпаны попытки. |
Read | In-app прочитано получателем (терминальное для Channel = InApp). |
Enum NotificationUrgency
| Значение | Описание |
|---|---|
Low | Информационное (лента/новости); дефер по тихим часам. |
Normal | Стандартное (default); interruptive-каналы уважают тихие часы. |
Critical | Безопасность/деньги/дедлайны/напоминания (SecurityNewSignIn, BookingExpiringSoon, BookingPaid, AppointmentReminder, CalendarReminder) — тихие часы игнорируются. |
Enum NotificationKind (бизнес-виды, по источникам)
Конечное множество; хранение text. Дом маппинга событие → Kind — в consumer-документах.
| Группа (источник) | Значения |
|---|---|
identity | SecurityNewSignIn, AccessChanged, CompanyInvitationReceived, CompanyInvitationResolved, AgentMembershipChanged, CompanySuspended, WelcomeRegistered |
profile | VerificationSubmitted, VerificationApproved, VerificationRejected |
crm | LeadCreated, LeadExpired, FixationApproved, FixationRejected, DealCreated, DealStageChanged, DealCompleted, DealCancelled, BookingCreated, BookingConfirmed, BookingPaid, BookingCancelled, BookingExpired, DocumentRequested, DocumentStatusChanged, DealMemberAdded, DealMemberRemoved, DealExecutorChanged, DealResponsibleChanged, LeadParticipantAdded, LeadParticipantRemoved, LeadExecutorChanged, LeadResponsibleChanged |
news | PostPublished, CommentAdded |
rewards | CommissionAccrued, CommissionStatusChanged, CommissionMoneyReceived, ReferralRewardAccrued, ReferralRewardStatusChanged, PayoutStatusChanged, RewardRuleChanged |
wallet | WalletCredited, WalletDebited, WalletAdjusted |
referral | ReferralBondChanged, ReferralLeadAttached |
investor-owner | PropertyAdded, PropertySold, OwnerLinked |
promotion | PromotionPurchaseChanged |
calendar | AppointmentCreated, AppointmentConfirmed, AppointmentCancelled, AppointmentReminder, CalendarReminder, CalendarEventRescheduled, CalendarEventOwnerChanged |
Value objects
RecipientAddress (owned)
Типизированный адрес канала (строгая типизация: заполняется поле, соответствующее Channel; не bare-string).
| Поле | Тип | Обязательность | Ограничения | Колонка |
|---|---|---|---|---|
Email | string? | Нет | varchar(256), RFC 5322; для Channel = Email | address_email |
PhoneE164 | string? | Нет | varchar(32), E.164; для Sms/WhatsApp/Viber | address_phone |
MessengerHandle | string? | Нет | varchar(128); telegram/max chat-id (резолвит адаптер) | address_handle |
InAppUserId | Guid? | Нет | = RecipientUserId для Channel = InApp | address_inapp_user_id |
Заполняется снапшотом из контактной реплики получателя (recipient-contact.md) на
момент enqueue (для канала берётся соответствующая точка), а не синхронным lookup в identity/profile.
Отсутствие точки канала в реплике не блокирует создание уведомления (in-app доступен всегда); внешний адрес
догоняется адаптером или следующим апдейтом реплики.
Модели JSONB (versioned)
PayloadModel (jsonb, ContractVersion = 1) — типизированный union по Kind
Строгая типизация: не bare json/Dictionary<string,object>. PayloadModel — дискриминированный
union по Kind-группе (базовый тип NotificationPayload с полем-дискриминатором Kind). Каждый
конкретный payload — тонкий типизированный снапшот полей исходного события; дом полей — event-contract
сервиса-источника (payload здесь только перечисляет используемые поля). Все пары id + имя — вложенные
ref-модели { Id, Name } (не плоские колонки).
| Конкретный payload | Kind-группа | Ключевые поля (типизированные) | Дом полей (event) |
|---|---|---|---|
DealNotificationPayload | crm (deal) | Deal { Id, Number }, PropertyObject { Id, Name }, Stage { Code, Name }?, CounterpartyName? | crm/events/deal-created.md и смежные |
BookingNotificationPayload | crm (booking) | Booking { Id }, PropertyObject { Id, Name }, ExpiresAt?, Amount (Money?) | crm/events/booking-created.md |
FixationNotificationPayload | crm (fixation) | Fixation { Id }, Client { Id, Name }, Developer { Id, Name }, Decision, Reason? | crm/events/fixation-approved.md |
DocumentNotificationPayload | crm (doc) | Deal { Id, Number }, DocumentKind, DocumentStatus? | crm/events/document-requested.md |
DealMembershipNotificationPayload | crm (deal membership) | Deal { Id, Number }, Role (ParticipantRole), Change (Added/Removed/ExecutorChanged/ResponsibleChanged), ActorName? | crm/events/deal-members-changed.md, deal-created.md |
LeadMembershipNotificationPayload | crm (lead membership) | Lead { Id }, Role (ParticipantRole), Change (Added/Removed/ExecutorChanged/ResponsibleChanged), ActorName? | crm/events/lead-participants-changed.md |
RewardNotificationPayload | rewards | Subject { Id } (accrual/payout/referral-accrual), Amount (Money), Status | rewards/events/ |
WalletNotificationPayload | wallet | Account { Id, Kind }, OperationKind, Direction, Amount (Money), BalanceAfter (Money) | wallet/events/wallet-transaction-recorded.md |
ReferralNotificationPayload | referral | Bond { Id }, Referred { Id, Name }?, State | referral/events/bond-created.md |
NewsNotificationPayload | news | Post { Id, Title }, Author { Id, Name }, CommentPreview? | news/events/post-published.md |
OwnershipNotificationPayload | investor-owner | PropertyObject { Id, Name }, Owner { Id, Name }? | investor-owner/events/property-added.md |
SecurityNotificationPayload | identity (sign-in) | SignedInAt, IpMasked, Device?, Location? | identity/events/user-signed-in.md |
AccessNotificationPayload | identity (access) | Scope, Role { Id, Name }?, Change | identity/events/user-access-changed.md |
InvitationNotificationPayload | identity (invite) | Company { Id, Name }, Role, InvitationId, State | forward pin.identity.company-invitation-changed.v1 (ADR-0061) |
VerificationNotificationPayload | profile | Request { Id }, Decision, Reason? | profile/events/verification-completed.md |
PromotionNotificationPayload | promotion | Purchase { Id }, PackageKind, Targets[] { Kind, Id }, Status, EndsAt? | forward pin.promotion.purchase-changed.v1 (ADR-0060) |
AppointmentNotificationPayload | calendar (appointment) | Appointment { Id }, EventKind, StartAt, EndAt?, TimeZone, Status?, Reason?, AutoConfirmed?, RescheduledToAppointmentId? | calendar/events/appointment-created.md и семейство appointment-* |
CalendarEventNotificationPayload | calendar (event) | Event { Id }, EventKind, Origin, StartAt, EndAt?, TimeZone, CancelReason?, Deal { Id }?, Booking { Id }?, PreviousOwner { Id }? (для CalendarEventOwnerChanged) | calendar/events/event-created.md, event-rescheduled, event-status-changed, event-owner-changed |
CalendarReminderNotificationPayload | calendar (reminder) | Reminder { Id }, Event { Id }, EventKind, StartAt, TimeZone, OffsetMinutes, Appointment { Id }? | calendar/events/reminder-due.md |
Money — payload-VO (Amount decimal + CurrencyId + CurrencySnapshot), выровненный с wallet/rewards:
идентичность валюты — по CurrencyId (handbook), а display-код Currency/Sign — производный плейсхолдер
из CurrencySnapshot (не самостоятельное поле). Диапазоны (NullableRange<T>) в payload не используются.
Типовые плейсхолдеры calendar-шаблонов — EventKindName (человекочитаемый вид встречи/события),
StartAtLocal (локальное время из StartAt + IANA TimeZone), OffsetHorizon (горизонт напоминания из
OffsetMinutes: «через сутки»/«через час»), CancelReason — биндятся из этих payload; допустимый набор на
Kind — notification-template.md.
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|---|---|---|---|
NotificationTemplateEntity | reference | TemplateId | загрузка при рендере тела | restrict |
ChannelConfigEntity | reference | Channel | поиск по коду канала при диспатче | restrict |
| Получатель / исходный агрегат | внешняя логическая | RecipientUserId / payload id | display — API источников | — |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
| INV-NTF-1 | Fan-out идемпотентен: (SourceMessageId, RecipientUserId, Channel, Kind) уникален — повтор события не создаёт дубль | уникальный индекс ux_notifications_dedup; при создании уведомления | NTF_DUPLICATE_SKIPPED (skip, ack) |
| INV-NTF-2 | Создание: Status = Pending, Attempts = 0; RenderedBody не пустой (шаблон резолвнут) | при создании уведомления | NTF_TEMPLATE_NOT_RESOLVED |
| INV-NTF-3 | Канал создаётся только если он разрешён UserNotificationSettings для Kind (opt-out → уведомление не создаётся) | резолв каналов перед созданием | — (пропуск канала) |
| INV-NTF-4 | Тихие часы: при попадании в окно и Urgency != Critical interruptive-канал получает ScheduledAt = открытие окна | применение тихих часов при создании | — |
| INV-NTF-5 | Переходы: Pending→Sent/Pending→Failed; Failed→Sent/Failed→Failed (retry); Sent→Read (только InApp). Sent внешнего канала — терминально | переходы статуса (отправка / сбой / прочтение) | NTF_STATUS_TRANSITION |
| INV-NTF-6 | Read доступен только Channel = InApp и RecipientUserId = actor | отметка о прочтении | NTF_READ_FORBIDDEN |
| INV-NTF-7 | Retry прекращается при Attempts >= MaxAttempts (по ChannelConfig) → Failed терминально + алерт | retry-worker (фон) | — |
Резолв каналов, промежуточное состояние и eventual consistency
Кандидатность канала при enqueue. Пара (получатель, канал) материализуется в NotificationEntity только
если одновременно: (1) ChannelConfig.IsEnabled = true (channel.md INV-CHN-2); (2) канал разрешён
UserNotificationSettings для Kind (INV-NTF-3, с учётом GlobalMute/Critical); (3) есть Active-тело
шаблона канала (INV-TPL-5). Любое false → канал пропускается, уведомление по нему не создаётся. Поэтому
пока внешние каналы — заглушки (IsEnabled = false), по ним Pending не появляется, и получатель видит только
in-app. Если канал выключают после enqueue — уже созданные Pending не удаляются: dispatcher держит их до
включения канала.
Таймлайн исхода (что видит пользователь в промежутке). Создание в Pending (+ idempotency-mark) — атомарно
на enqueue; далее по каналу:
- In-app (реальный):
Pending → Sentпри realtime-push (best-effort), запись уже в «колокольчике» — оффлайн-клиент догонит приlist/reconnect;Sent → Readтолько поmark-read. Окно до появления бейджа — секунды. - Внешние каналы (заглушки): остаются
Pending/переходят вFailedдо активации провайдера; для получателя внешнее уведомление в первой версии не приходит. ScheduledAt(тихие часы, interruptive-канал,Urgency != Critical):Pendingне берётся dispatcher-ом до наступления окна;Critical— сразу.
Кросс-источниковый дедуп (граница INV-NTF-1). Идемпотентность fan-out защищает от повтора одного
исходного события (SourceMessageId). Два разных события (разные MessageId) об одном факте (напр.
verification-completed и последующий person-upsert со статусом верификации) индексом ux_notifications_dedup
не склеиваются — устранение двойного оповещения решается на стороне consumer выбором авторитетного источника
(profile-events.md).
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|---|---|---|
NotificationCreated / NotificationStatusChanged | доменное | создание / смена Status | внутренние подписчики (in-app push через realtime-канал, счётчик непрочитанных) синхронно при обработке |
NotificationDispatchedEvent | интеграционное, топик pin.notifications.dispatched.v1, ContractVersion = 1 | Переход в Sent/терминальный Failed | NotificationId, RecipientUserId, Kind, Channel, Status, Attempts, DispatchedAt; опционально (аналитика) — ../events/notification-dispatched.md |
Доменные события поднимаются при создании и смене статуса уведомления; интеграционное dispatched
публикуется через транзакционный outbox атомарно со сменой состояния агрегата.
Индексы, хранение, безопасность
| Аспект | Значение |
|---|---|
| Индексы | PK; ux_notifications_dedup (SourceMessageId, RecipientUserId, Channel, Kind) unique; ix_notifications_recipient (RecipientUserId, Status); ix_notifications_created_at desc; partial (ScheduledAt) where status='Pending'; partial (NextAttemptAt) where status='Failed'; ix_notifications_kind |
| Поиск | не требуется (выборка in-app — по RecipientUserId + пагинация; ADR-0059) |
| Партиционирование / retention | retention in-app/терминальных — soft-delete + фоновая очистка (напр. > 180 дней); партиций нет (объём мал, ADR-0056) |
| Конкурентный доступ | оптимистичная блокировка версии записи: гонка dispatcher/retry ↔ mark-read (правка не должна затирать более свежее состояние) |
| Permissions / PII | notifications.inbox.read (own), notifications.settings.manage (own). PII: RecipientAddress/RenderedBody — маска в логах, наружу — только владельцу (RecipientUserId = actor) |
| Audit | отметки создания/изменения/удаления (кто и когда) + версия записи + статусные таймстемпы (SentAt/FailedAt/ReadAt) + Attempts/LastError |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (4): Entity / Aggregate: Channel (enum) + ChannelConfigEntity, Entity / Aggregate: NotificationTemplateEntity, Entity / Aggregate: RecipientContactEntity, Entity / Aggregate: UserNotificationSettingsEntity
- API (4): GET /api/public/notifications/inbox — Мои in-app уведомления, POST /api/internal/notifications/enqueue — Прямой enqueue уведомления (m2m), POST /api/public/notifications/inbox/{notificationId}/read — Отметить прочитанным, PUT /api/public/notifications/settings — Обновить мои настройки уведомлений
- Use Cases (4): UC-CAL-008. Напоминания о событиях календаря, UC-NTF-001. Пользователь настраивает уведомления, UC-NTF-002. Событие сделки → многоканальное уведомление с учётом настроек, UC-NTF-003. Сбой провайдера → повтор доставки
- События (1): Event Contract: pin.notifications.dispatched
- Консюмеры (10): Consumer: Calendar Events → Notifications (notifications-calendar-events), Consumer: CRM Events → Notifications (notifications-crm-events), 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)
- Фоновые задачи (1): Background Job: NotificationRetryWorker
- index.md (1): Notifications Service
- providers (8): Provider Adapter: Email Messaging (заглушка), Provider Adapter: In-App (Talent.Common.Realtime) Messaging, Provider Adapter: MAX Messaging (заглушка), Provider Adapter: Push Messaging (заглушка), Provider Adapter: SMS Messaging (заглушка), Provider Adapter: Telegram Messaging (заглушка), Provider Adapter: Viber Messaging (заглушка), Provider Adapter: WhatsApp Messaging (заглушка)
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
Связанные документы
- notification-template.md (
TemplateId, рендер, плейсхолдеры), channel.md (Channel/ChannelConfig), user-notification-settings.md (opt-in/out, тихие часы), recipient-contact.md (реплика контактов — источникRecipientAddress/Locale). - Событие — ../events/notification-dispatched.md; фон — ../background/notification-retry.md; провайдеры — ../providers/.
- README контекста; tenancy — ADR-0052, масштаб/фон — ADR-0056.