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

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
KindAggregateRoot
Source of truthnotifications
Таблицаnotifications.notifications
Tenant isolationTenantId = компания получателя (RecipientCompanyId) или null (инвестор вне tenant), ADR-0052

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappИдентификатор уведомления.
TenantIdGuid?Нетindex where not nullappКомпания получателя (null — инвестор).
RecipientUserIdGuidДаindex ix_notifications_recipient (composite с Status)событие/enqueueПолучатель; логическая ссылка identity.users(id).
RecipientCompanyIdGuid?НетсобытиеКомпания получателя (контекст).
KindNotificationKindДаenum (хранится как text), varchar(48), indexappСемантический вид (см. enum).
ChannelChannelДаenum (хранится как text), varchar(16)appКанал доставки (channel.md).
TemplateIdGuidДаFK fk_notifications_template → notifications.notification_templates(id)резолвШаблон (Kind, Locale), из которого рендерилось тело.
LocalestringДаvarchar(8), default rusettings/userЛокаль рендера — BCP-47 из закрытого списка (дом — profile.md); НЕ LanguageId справочника handbook (ADR-0063).
PayloadPayloadModelДаjsonb payload, versioned (ContractVersion)событиеТипизированный снапшот данных (см. ниже).
RenderedTitlestring?Нетvarchar(256)рендерЗаголовок (для in-app/push); null — канал без заголовка (sms).
RenderedBodystringДаtextрендерТело после подстановки плейсхолдеров.
DeepLinkstring?Нетvarchar(1024)рендерДиплинк в приложение (карточка сделки/поста/лота).
RecipientAddressRecipientAddress (owned)Нетсм. VO нижереплика/adapterСнапшот адреса канала на момент enqueue из контактной реплики recipient-contact.md (null — точки нет, догоняет адаптер/следующий retry).
StatusNotificationStatusДаenum (хранится как text), varchar(16), indexapp/dispatcherСтатус доставки (см. enum).
SourceServicestringДаvarchar(32)consumerКонтекст-источник (crm/news/…) — для аудита/аналитики.
SourceTopicstring?Нетvarchar(96)consumerТопик исходного события (null для m2m enqueue).
SourceMessageIdGuidДаunique ux_notifications_dedup (SourceMessageId, RecipientUserId, Channel, Kind)envelopeИдемпотентность fan-out (MessageId события).
UrgencyNotificationUrgencyДаenum (хранится как text), default NormalKind-политикаВлияет на тихие часы (Critical игнорирует quiet hours).
ScheduledAtDateTimeOffset?Нетindex partial where status = 'Pending'app/quiet-hoursНе отправлять раньше (дефер interruptive-каналов на окно).
AttemptsintДаdefault 0, >= 0dispatcherЧисло попыток отправки.
NextAttemptAtDateTimeOffset?Нетindex partial where status = 'Failed'retry-workerМомент следующего повтора (backoff).
LastErrorstring?Нетvarchar(512)adapterПоследняя ошибка провайдера (безопасное сообщение).
ProviderMessageIdstring?Нетvarchar(128)adapterВнешний id доставки (delivery-receipt провайдера).
SentAtDateTimeOffset?НетdispatcherМомент перехода в Sent.
FailedAtDateTimeOffset?НетdispatcherМомент последней неудачи.
ReadAtDateTimeOffset?НетполучательIn-app прочитано (mark-read).
CreatedAtDateTimeOffsetДаindex ix_notifications_created_at descappСоздание (enqueue).
UpdatedAtDateTimeOffsetДаappМомент последнего изменения.
DeletedAtDateTimeOffset?НетretentionSoft-delete (авто-очистка).

Enum NotificationStatus

ЗначениеОписание
PendingСоздано, ожидает отправки dispatcher-ом (учитывает ScheduledAt).
SentОтправлено провайдером/доставлено in-app (терминальное для внешних каналов).
FailedОтправка не удалась; ждёт retry (NextAttemptAt) или исчерпаны попытки.
ReadIn-app прочитано получателем (терминальное для Channel = InApp).

Enum NotificationUrgency

ЗначениеОписание
LowИнформационное (лента/новости); дефер по тихим часам.
NormalСтандартное (default); interruptive-каналы уважают тихие часы.
CriticalБезопасность/деньги/дедлайны/напоминания (SecurityNewSignIn, BookingExpiringSoon, BookingPaid, AppointmentReminder, CalendarReminder) — тихие часы игнорируются.

Enum NotificationKind (бизнес-виды, по источникам)

Конечное множество; хранение text. Дом маппинга событие → Kind — в consumer-документах.

Группа (источник)Значения
identitySecurityNewSignIn, AccessChanged, CompanyInvitationReceived, CompanyInvitationResolved, AgentMembershipChanged, CompanySuspended, WelcomeRegistered
profileVerificationSubmitted, VerificationApproved, VerificationRejected
crmLeadCreated, LeadExpired, FixationApproved, FixationRejected, DealCreated, DealStageChanged, DealCompleted, DealCancelled, BookingCreated, BookingConfirmed, BookingPaid, BookingCancelled, BookingExpired, DocumentRequested, DocumentStatusChanged, DealMemberAdded, DealMemberRemoved, DealExecutorChanged, DealResponsibleChanged, LeadParticipantAdded, LeadParticipantRemoved, LeadExecutorChanged, LeadResponsibleChanged
newsPostPublished, CommentAdded
rewardsCommissionAccrued, CommissionStatusChanged, CommissionMoneyReceived, ReferralRewardAccrued, ReferralRewardStatusChanged, PayoutStatusChanged, RewardRuleChanged
walletWalletCredited, WalletDebited, WalletAdjusted
referralReferralBondChanged, ReferralLeadAttached
investor-ownerPropertyAdded, PropertySold, OwnerLinked
promotionPromotionPurchaseChanged
calendarAppointmentCreated, AppointmentConfirmed, AppointmentCancelled, AppointmentReminder, CalendarReminder, CalendarEventRescheduled, CalendarEventOwnerChanged

Value objects

RecipientAddress (owned)

Типизированный адрес канала (строгая типизация: заполняется поле, соответствующее Channel; не bare-string).

ПолеТипОбязательностьОграниченияКолонка
Emailstring?Нетvarchar(256), RFC 5322; для Channel = Emailaddress_email
PhoneE164string?Нетvarchar(32), E.164; для Sms/WhatsApp/Viberaddress_phone
MessengerHandlestring?Нетvarchar(128); telegram/max chat-id (резолвит адаптер)address_handle
InAppUserIdGuid?Нет= RecipientUserId для Channel = InAppaddress_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 } (не плоские колонки).

Конкретный payloadKind-группаКлючевые поля (типизированные)Дом полей (event)
DealNotificationPayloadcrm (deal)Deal { Id, Number }, PropertyObject { Id, Name }, Stage { Code, Name }?, CounterpartyName?crm/events/deal-created.md и смежные
BookingNotificationPayloadcrm (booking)Booking { Id }, PropertyObject { Id, Name }, ExpiresAt?, Amount (Money?)crm/events/booking-created.md
FixationNotificationPayloadcrm (fixation)Fixation { Id }, Client { Id, Name }, Developer { Id, Name }, Decision, Reason?crm/events/fixation-approved.md
DocumentNotificationPayloadcrm (doc)Deal { Id, Number }, DocumentKind, DocumentStatus?crm/events/document-requested.md
DealMembershipNotificationPayloadcrm (deal membership)Deal { Id, Number }, Role (ParticipantRole), Change (Added/Removed/ExecutorChanged/ResponsibleChanged), ActorName?crm/events/deal-members-changed.md, deal-created.md
LeadMembershipNotificationPayloadcrm (lead membership)Lead { Id }, Role (ParticipantRole), Change (Added/Removed/ExecutorChanged/ResponsibleChanged), ActorName?crm/events/lead-participants-changed.md
RewardNotificationPayloadrewardsSubject { Id } (accrual/payout/referral-accrual), Amount (Money), Statusrewards/events/
WalletNotificationPayloadwalletAccount { Id, Kind }, OperationKind, Direction, Amount (Money), BalanceAfter (Money)wallet/events/wallet-transaction-recorded.md
ReferralNotificationPayloadreferralBond { Id }, Referred { Id, Name }?, Statereferral/events/bond-created.md
NewsNotificationPayloadnewsPost { Id, Title }, Author { Id, Name }, CommentPreview?news/events/post-published.md
OwnershipNotificationPayloadinvestor-ownerPropertyObject { Id, Name }, Owner { Id, Name }?investor-owner/events/property-added.md
SecurityNotificationPayloadidentity (sign-in)SignedInAt, IpMasked, Device?, Location?identity/events/user-signed-in.md
AccessNotificationPayloadidentity (access)Scope, Role { Id, Name }?, Changeidentity/events/user-access-changed.md
InvitationNotificationPayloadidentity (invite)Company { Id, Name }, Role, InvitationId, Stateforward pin.identity.company-invitation-changed.v1 (ADR-0061)
VerificationNotificationPayloadprofileRequest { Id }, Decision, Reason?profile/events/verification-completed.md
PromotionNotificationPayloadpromotionPurchase { Id }, PackageKind, Targets[] { Kind, Id }, Status, EndsAt?forward pin.promotion.purchase-changed.v1 (ADR-0060)
AppointmentNotificationPayloadcalendar (appointment)Appointment { Id }, EventKind, StartAt, EndAt?, TimeZone, Status?, Reason?, AutoConfirmed?, RescheduledToAppointmentId?calendar/events/appointment-created.md и семейство appointment-*
CalendarEventNotificationPayloadcalendar (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
CalendarReminderNotificationPayloadcalendar (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; допустимый набор на Kindnotification-template.md.

Связи

СвязьТипКлючПравило загрузкиDelete behavior
NotificationTemplateEntityreferenceTemplateIdзагрузка при рендере телаrestrict
ChannelConfigEntityreferenceChannelпоиск по коду канала при диспатчеrestrict
Получатель / исходный агрегатвнешняя логическаяRecipientUserId / payload iddisplay — API источников

Инварианты и переходы состояний

ИнвариантУсловиеГде проверяетсяОшибка
INV-NTF-1Fan-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-6Read доступен только Channel = InApp и RecipientUserId = actorотметка о прочтенииNTF_READ_FORBIDDEN
INV-NTF-7Retry прекращается при 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/терминальный FailedNotificationId, 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)
Партиционирование / retentionretention in-app/терминальных — soft-delete + фоновая очистка (напр. > 180 дней); партиций нет (объём мал, ADR-0056)
Конкурентный доступоптимистичная блокировка версии записи: гонка dispatcher/retry ↔ mark-read (правка не должна затирать более свежее состояние)
Permissions / PIInotifications.inbox.read (own), notifications.settings.manage (own). PII: RecipientAddress/RenderedBody — маска в логах, наружу — только владельцу (RecipientUserId = actor)
Auditотметки создания/изменения/удаления (кто и когда) + версия записи + статусные таймстемпы (SentAt/FailedAt/ReadAt) + Attempts/LastError

Обратные ссылки

Автоссылки: где используется этот документ.

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