Entity / Aggregate: RecipientContactEntity
Дом полей агрегата «Контактная реплика получателя» контекста notifications (greenfield). Проекции
(снапшот адреса на notification.md, резолв каналов/локали при enqueue) ссылаются сюда.
Назначение
RecipientContactEntity — долгоживущая локальная реплика контактных точек получателя: ФИО, адреса
каналов (телефон; email/мессенджер-handle — по мере появления источника), предпочитаемая локаль и статус
учётки. Ведётся событийно, а не синхронным lookup-ом в identity/profile на каждый enqueue. Основной
источник — identity (user-upserted.md — ФИО/телефон/статус/tenant,
уже потребляется группой identity-events.md). Email/мессенджер-handle/локаль
поступают из profile при наличии контактного события с этими полями: текущий
person-upserted.md — PII-free (email/локаль в нём нет), поэтому
до появления такого контракта соответствующие точки остаются пустыми (forward). Бизнес-ключ — UserId (одна
запись на пользователя). При enqueue consumer читает реплику получателя и снимает с неё снапшот
RecipientAddress + Locale для уведомления (notification.md); отсутствие точки канала не
блокирует обработку — деградация graceful (in-app доступен всегда, внешний адрес догоняется следующим апдейтом
реплики/адаптером). Настройки доставки (opt-in/out, тихие часы) хранятся отдельно
(user-notification-settings.md); реплика поставляет только контакты/локаль.
Классификация
| Поле | Значение |
|---|
| Контекст | notifications |
| Kind | AggregateRoot (owned VO ContactName) |
| Source of truth | внешний (identity — ФИО/телефон/статус; profile — email/локаль, forward при появлении контракта); notifications держит реплику |
| Таблица | notifications.recipient_contacts |
| Tenant isolation | INullableTenantedEntity.TenantId = компания пользователя или null (инвестор), ADR-0052 |
Реализуемые capability-интерфейсы (Talent.Common.Primitives.Entity)
| Интерфейс | Применяется | Комментарий |
|---|
IEntity (Guid) | Да | Id = Guid.CreateVersion7(). |
INullableTenantedEntity | Да | TenantId = компания пользователя или null. |
IAuditableEntity / IModifiedEntity | Да | Actor = система (консюмер), запись ведётся из событий. |
IVersionedEntity / IOptimisticBlockedEntity | Да | Version + RowVersion (xmin) — гонки апдейтов реплики. |
IEntityWithStatus / ISoftDeleteEntity / IDocument / ICodeEntity | Нет | статус учётки — отдельное поле RecipientStatus; soft-delete/jsonb нет; ключ — UserId. |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|
Id | Guid | Да | PK | app | Идентификатор записи. |
UserId | Guid | Да | unique ux_recipient_contacts_user_id | событие | Получатель (бизнес-ключ; логическая ссылка identity.users(id)). |
TenantId | Guid? | Нет | index where not null | identity | Компания пользователя (null — инвестор). |
Name | ContactName (owned) | Нет | см. VO ниже | identity | ФИО-снапшот (плейсхолдер обращения в шаблонах). |
Email | string? | Нет | varchar(256), RFC 5322 | profile (forward) | Адрес канала Email (null — источника пока нет: user-upserted email не несёт, persons.v1 PII-free). |
PhoneE164 | string? | Нет | varchar(32), E.164 | identity | Адрес каналов Sms/WhatsApp/Viber (из user-upserted.PhoneNumber; null — не заполнен). |
MessengerHandle | string? | Нет | varchar(128) | profile (forward) | Telegram/max handle (если появится источник; иначе резолвит адаптер). |
Locale | string | Да | varchar(8) (BCP-47), default ru | profile (forward) | Предпочитаемая локаль-реплика (сид резолва шаблона; до появления источника — ru; см. INV-RC-4). |
RecipientStatus | RecipientStatus | Да | enum (хранится как text), varchar(16), default Active | identity | Состояние учётки получателя (см. enum). |
IdentityVersion | long | Да | >= 0, default 0 | identity | Монотонный guard апдейтов из identity (не затирать новее старым). |
IdentitySyncedAt | DateTimeOffset? | Нет | — | app | Момент последнего апдейта из identity (наблюдаемость свежести). |
ProfileSyncedAt | DateTimeOffset? | Нет | — | app | Момент последнего апдейта из profile (null — профиль ещё не реплицирован). |
CreatedAt/UpdatedAt | DateTimeOffset | Да | — | app | Тайминги (IModifiedEntity). |
Version | long | Да | >= 1 | app | IVersionedEntity. |
RowVersion | uint | Да | concurrency (xmin) | db | IOptimisticBlockedEntity. |
Enum RecipientStatus
Отражает статус учётки из identity (UserStatus: Invited/Active/Blocked/Archived) в разрезе
пригодности внешних каналов (влияет на подавление доставки).
| Значение | Маппинг UserStatus | Описание |
|---|
Active | Invited/Active | Учётка контактопригодна; внешние каналы заполняются из реплики. |
Blocked | Blocked | Учётка заблокирована (user-blocked.md); внешние каналы подавляются, in-app сохраняется. |
Archived | Archived | PII удалена/анонимизирована (delete-user); контактные точки очищены (INV-RC-3). |
Value objects / EF owned types
ФИО-снапшот получателя (строгая типизация, не bare-string). Соответствует FullNameSnapshot
user-upserted.md.
| Поле | Тип | Обязательность | Ограничения | Колонка |
|---|
FirstName | string | Да | varchar(128), min 1 | name_first |
LastName | string | Да (пустая строка допустима) | varchar(128) | name_last |
MiddleName | string? | Нет | varchar(128) | name_middle |
Модели JSONB (IDocument, versioned)
JSONB-модели не используются: контактные точки — плоские колонки, ФИО — owned VO.
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|
| Получатель | внешняя логическая | UserId | FindByUserId при enqueue (снапшот адреса) | — |
UserNotificationSettingsEntity | внешняя логическая (тот же UserId) | UserId | резолв каналов/тихих часов — отдельно | — |
Инварианты и переходы состояний
Статус-машины домена нет (RecipientStatus — зеркало identity). Инварианты ведения реплики и применения:
| Инвариант | Условие | Где проверяется | Ошибка |
|---|
| INV-RC-1 | Одна запись на UserId (ux_recipient_contacts_user_id); first-seen UserId создаёт реплику, повтор — обновляет | unique + upsert реплики | — |
| INV-RC-2 | Монотонность из identity: апдейт применяется только если Version >= IdentityVersion (устаревшее событие — no-op); поля источника замещаются целиком, а не мёржатся дельтой | апдейт реплики | — (skip stale) |
| INV-RC-3 | Анонимизация (Archived, наибольшая Version) очищает Name/Email/PhoneE164/MessengerHandle; далее — только in-app | апдейт реплики | — |
| INV-RC-4 | Резолв локали при enqueue: явная Locale из user-notification-settings.md, иначе RecipientContact.Locale, иначе ru | резолв шаблона перед рендером | — |
| INV-RC-5 | Снапшот адреса при enqueue: RecipientAddress заполняется из реплики по Channel; отсутствующая точка → адрес пуст (не ошибка), in-app всегда доступен (InAppUserId = UserId) | резолв доставки | — (graceful) |
Ведение реплики (источники и апдейт)
| Источник | Топик | Что обновляет | Guard |
|---|
identity (user-upserted) | pin.identity.users.v1 | Name, PhoneE164, RecipientStatus, TenantId, IdentityVersion | Version >= IdentityVersion (INV-RC-2) |
identity (user-blocked) | pin.identity.users.v1 | RecipientStatus = Blocked | тот же guard |
profile (forward) | контактное событие profile (при появлении) | Email, MessengerHandle, Locale, ProfileSyncedAt | last-write по событию профиля |
Актуальный источник — identity user-upserted (уже потребляется, identity-events.md);
реплика ведётся в том же согласованном изменении, что и отметка идемпотентности потребления. Email/локаль/handle —
forward: pin.profile.persons.v1 намеренно PII-free, поэтому эти точки заполнятся, когда profile опубликует
контактное событие; до тех пор Email/MessengerHandle пусты, Locale = ru. До появления реплики (событие
домена опередило user-upserted) enqueue проходит graceful: заполняется in-app, внешний адрес пуст и догоняется,
когда реплика появится (INV-RC-5).
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|
RecipientContactUpdated | IDomainEvent | Апдейт реплики | внутренний invalidate кэша резолва адреса (in-process) |
Интеграционных IKafkaEvent не публикует (внутренняя реплика; source of truth — identity/profile).
Индексы, хранение, безопасность
| Аспект | Значение |
|---|
| Индексы | PK; ux_recipient_contacts_user_id unique; TenantId partial where not null |
| Партиционирование / retention | none (одна строка на пользователя; анонимизация очищает PII, строка сохраняется) |
| Concurrency | RowVersion (xmin); IdentityVersion — монотонный guard источника |
| Permissions / PII | не читается пользователем напрямую (внутренняя реплика); Email/PhoneE164/Name — PII, в логах маскируются (+7***1234, a***@x.ru) |
| Audit | IAuditableEntity + Version; IdentitySyncedAt/ProfileSyncedAt — свежесть источников |
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы