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

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.mdPII-free (email/локаль в нём нет), поэтому до появления такого контракта соответствующие точки остаются пустыми (forward). Бизнес-ключ — UserId (одна запись на пользователя). При enqueue consumer читает реплику получателя и снимает с неё снапшот RecipientAddress + Locale для уведомления (notification.md); отсутствие точки канала не блокирует обработку — деградация graceful (in-app доступен всегда, внешний адрес догоняется следующим апдейтом реплики/адаптером). Настройки доставки (opt-in/out, тихие часы) хранятся отдельно (user-notification-settings.md); реплика поставляет только контакты/локаль.

Классификация

ПолеЗначение
Контекстnotifications
KindAggregateRoot (owned VO ContactName)
Source of truthвнешний (identity — ФИО/телефон/статус; profile — email/локаль, forward при появлении контракта); notifications держит реплику
Таблицаnotifications.recipient_contacts
Tenant isolationINullableTenantedEntity.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)ИсточникОписание
IdGuidДаPKappИдентификатор записи.
UserIdGuidДаunique ux_recipient_contacts_user_idсобытиеПолучатель (бизнес-ключ; логическая ссылка identity.users(id)).
TenantIdGuid?Нетindex where not nullidentityКомпания пользователя (null — инвестор).
NameContactName (owned)Нетсм. VO нижеidentityФИО-снапшот (плейсхолдер обращения в шаблонах).
Emailstring?Нетvarchar(256), RFC 5322profile (forward)Адрес канала Email (null — источника пока нет: user-upserted email не несёт, persons.v1 PII-free).
PhoneE164string?Нетvarchar(32), E.164identityАдрес каналов Sms/WhatsApp/Viber (из user-upserted.PhoneNumber; null — не заполнен).
MessengerHandlestring?Нетvarchar(128)profile (forward)Telegram/max handle (если появится источник; иначе резолвит адаптер).
LocalestringДаvarchar(8) (BCP-47), default ruprofile (forward)Предпочитаемая локаль-реплика (сид резолва шаблона; до появления источника — ru; см. INV-RC-4).
RecipientStatusRecipientStatusДаenum (хранится как text), varchar(16), default ActiveidentityСостояние учётки получателя (см. enum).
IdentityVersionlongДа>= 0, default 0identityМонотонный guard апдейтов из identity (не затирать новее старым).
IdentitySyncedAtDateTimeOffset?НетappМомент последнего апдейта из identity (наблюдаемость свежести).
ProfileSyncedAtDateTimeOffset?НетappМомент последнего апдейта из profile (null — профиль ещё не реплицирован).
CreatedAt/UpdatedAtDateTimeOffsetДаappТайминги (IModifiedEntity).
VersionlongДа>= 1appIVersionedEntity.
RowVersionuintДаconcurrency (xmin)dbIOptimisticBlockedEntity.

Enum RecipientStatus

Отражает статус учётки из identity (UserStatus: Invited/Active/Blocked/Archived) в разрезе пригодности внешних каналов (влияет на подавление доставки).

ЗначениеМаппинг UserStatusОписание
ActiveInvited/ActiveУчётка контактопригодна; внешние каналы заполняются из реплики.
BlockedBlockedУчётка заблокирована (user-blocked.md); внешние каналы подавляются, in-app сохраняется.
ArchivedArchivedPII удалена/анонимизирована (delete-user); контактные точки очищены (INV-RC-3).

Value objects / EF owned types

ContactName (owned)

ФИО-снапшот получателя (строгая типизация, не bare-string). Соответствует FullNameSnapshot user-upserted.md.

ПолеТипОбязательностьОграниченияКолонка
FirstNamestringДаvarchar(128), min 1name_first
LastNamestringДа (пустая строка допустима)varchar(128)name_last
MiddleNamestring?Нетvarchar(128)name_middle

Модели JSONB (IDocument, versioned)

JSONB-модели не используются: контактные точки — плоские колонки, ФИО — owned VO.

Связи

СвязьТипКлючПравило загрузкиDelete behavior
Получательвнешняя логическаяUserIdFindByUserId при 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.v1Name, PhoneE164, RecipientStatus, TenantId, IdentityVersionVersion >= IdentityVersion (INV-RC-2)
identity (user-blocked)pin.identity.users.v1RecipientStatus = Blockedтот же guard
profile (forward)контактное событие profile (при появлении)Email, MessengerHandle, Locale, ProfileSyncedAtlast-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/поля
RecipientContactUpdatedIDomainEventАпдейт репликивнутренний invalidate кэша резолва адреса (in-process)

Интеграционных IKafkaEvent не публикует (внутренняя реплика; source of truth — identity/profile).

Индексы, хранение, безопасность

АспектЗначение
ИндексыPK; ux_recipient_contacts_user_id unique; TenantId partial where not null
Партиционирование / retentionnone (одна строка на пользователя; анонимизация очищает PII, строка сохраняется)
ConcurrencyRowVersion (xmin); IdentityVersion — монотонный guard источника
Permissions / PIIне читается пользователем напрямую (внутренняя реплика); Email/PhoneE164/Name — PII, в логах маскируются (+7***1234, a***@x.ru)
AuditIAuditableEntity + Version; IdentitySyncedAt/ProfileSyncedAt — свежесть источников

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

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

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