Entity / Aggregate: ContactEntity
Дом полей агрегата «контакт-связь» контекста messenger. Все проекции (списки контактов и входящих
заявок, маскированная/полная карточка профиля, payload pin.messenger.contact-request-created.v1 и
pin.messenger.contact-request-accepted.v1) ссылаются сюда и описывают только дельту, а не
переописывают поля.
Назначение
ContactEntity — направленная заявка на контакт и её жизненный цикл до двусторонней связи. Инициатор
(Requester) отправляет заявку адресату (Addressee); при подтверждении связь становится двусторонней:
обе стороны видят полный профиль друг друга (ФИО + телефон, дом — messenger-user.md)
и получают право звонка (call-session.md, INV-CALL-1). До подтверждения профиль
маскирован (фамилия — первой буквой), звонок недоступен.
Aggregate boundary — одна запись связи для неупорядоченной пары пользователей; source of truth — messenger.
Справочник пользователей — replica-контекст messenger-user; чёрные списки — blacklist.md.
Классификация
| Поле | Значение |
|---|---|
| Контекст | messenger |
| Kind | AggregateRoot |
| Source of truth | messenger |
| Таблица | messenger.contacts |
| Tenant isolation | нет — граф контактов person-level; изоляция record-level по участникам пары (RequesterUserId / AddresseeUserId) |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор связи. |
RequesterUserId | Guid | Да | index ix_contacts_requester (RequesterUserId,Status); FK-логическая → messenger-user | app (актор) | Инициатор заявки. |
AddresseeUserId | Guid | Да | index ix_contacts_addressee (AddresseeUserId,Status); FK-логическая → messenger-user | app (форма заявки) | Адресат заявки. |
PairKey | string | Да | varchar(128); unique partial ux_contacts_active_pair (PairKey) where Status in ('Pending','Accepted') | app (детерминированно) | Ключ неупорядоченной пары {min}:{max} из двух Guid (N-формат): Requester/Addressee в порядке возрастания — заявки A→B и B→A дают один PairKey (INV-CT-2). |
Status | ContactStatus | Да | .HasConversion<string>(), varchar(16); ведущий столбец composite-индексов requester/addressee | app | См. enum и переходы. |
RequestMessage | string? | Нет | varchar(512) | app (форма заявки) | Сопроводительный текст заявки; null — без сообщения. |
RespondedAt | DateTimeOffset? | Нет | — | app | Момент ответа (Accept/Decline/Cancel); null — ещё Pending. |
RespondedByUserId | Guid? | Нет | — | app | Кто закрыл заявку: адресат (Accept/Decline) или сторона-инициатор ремува/отзыва. |
CreatedAt | DateTimeOffset | Да | index (через composite) | app | Момент подачи заявки. |
CreatedByUserId | Guid? | Нет | — | app | Кто создал заявку; совпадает с RequesterUserId. |
UpdatedAt | DateTimeOffset | Да | — | app | Момент последнего изменения. |
UpdatedByUserId | Guid? | Нет | — | app | Последний изменивший. |
DeletedAt | DateTimeOffset? | Нет | — | app | Soft-remove контакта; выводит строку из ux_contacts_active_pair. |
DeletedByUserId | Guid? | Нет | — | app | Кто удалил контакт. |
Enum ContactStatus
| Значение | Описание |
|---|---|
Pending | Заявка отправлена, ждёт ответа адресата. |
Accepted | Подтверждён — двусторонний контакт (полный профиль + право звонка). |
Declined | Отклонён адресатом. |
Cancelled | Отозван инициатором (до ответа) или удалён любой стороной (после Accepted). |
Value objects
Value objects не используются. PairKey — вычисляемая скалярная строка (детерминированная функция пары),
хранится материализованно ради partial-unique-индекса. ФИО (FullName) и телефон — дом
messenger-user.md, в этот агрегат не проецируются.
Модели JSONB (versioned)
JSONB-модели не используются.
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|---|---|---|---|
MessengerUserEntity (инициатор) | replica-reference | RequesterUserId | проверка существования при создании (INV-CT-6) | restrict |
MessengerUserEntity (адресат) | replica-reference | AddresseeUserId | проверка существования при создании (INV-CT-6) | restrict |
BlacklistEntity | reference (guard) | пара Requester/Addressee | проверка при создании (INV-CT-4) | — |
CallSessionEntity | обратная (право звонка) | пара | активна только при Status = Accepted (INV-CT-5) | — |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
| INV-CT-1 | Нельзя заявка на себя: RequesterUserId != AddresseeUserId | при создании заявки + check ck_contacts_not_self | MSG_CONTACT_SELF |
| INV-CT-2 | Не более одной активной связи на пару. Повторная заявка при существующем Pending/Accepted → конфликт (или возврат существующей) | при создании: unique partial ux_contacts_active_pair + проверка активной связи по PairKey | MSG_CONTACT_EXISTS |
| INV-CT-3 | Pending→Accepted/Declined — только AddresseeUserId; Pending/Accepted→Cancelled — RequesterUserId (отзыв) либо любая сторона (удаление контакта) | переходы Accept / Decline / Cancel (проверка актора) | MSG_CONTACT_FORBIDDEN / MSG_CONTACT_INVALID_STATE |
| INV-CT-4 | Нельзя создать заявку, если пара в чёрном списке любой стороны | guard по blacklist.md при создании заявки | MSG_CONTACT_BLOCKED |
| INV-CT-5 | Accepted ⇒ обе стороны получают полный профиль (ФИО + телефон, messenger-user.md) и право звонка (call-session.md INV-CALL-1); до Accepted профиль маскирован (фамилия — первой буквой) | проекция карточки профиля + guard создания звонка | MSG_CONTACT_NOT_ACCEPTED |
| INV-CT-6 | Обе стороны — существующие пользователи | проверка существования обоих пользователей при создании | MSG_CONTACT_USER_NOT_FOUND |
Конкурентность и eventual-consistency. Встречные заявки A→B и B→A, поданные одновременно, дают один
PairKey: партиальный уникальный ux_contacts_active_pair пропускает одну (первую) как Pending, вторая
упирается в конфликт MSG_CONTACT_EXISTS (или ей возвращается уже созданная связь). Одновременные Accept и
Cancel разрешает оптимистическая блокировка: проигравший перечитывает состояние и следует переходам INV-CT-3.
Проверка существования сторон (INV-CT-6) опирается на реплику messenger-user, которая eventual: только что
зарегистрированный адресат может ещё отсутствовать в реплике → MSG_CONTACT_USER_NOT_FOUND (допустимая гонка,
сходится после user-upserted). Блокировка (INV-CT-4), добавленная между подачей и подтверждением, не
откатывает Accepted-связь, но CommunicationPolicy немедленно запрещает общение по паре
(blacklist.md).
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|---|---|---|
ContactRequestCreated | доменное | Создан Pending | Id, RequesterUserId, AddresseeUserId |
ContactRequestAccepted | доменное | Pending → Accepted | Id, RequesterUserId, AddresseeUserId, RespondedByUserId |
ContactDeclined / ContactCancelled | доменное | → Declined / → Cancelled | Id, RespondedByUserId — push в SignalR-hub (ContactRequestDeclined/ContactRemoved), без Kafka |
ContactRequestCreatedEvent | интеграционное, topic pin.messenger.contact-request-created.v1, ContractVersion = 1 | Создан Pending | ContactId, RequesterUserId, AddresseeUserId, RequestMessage?, CreatedAt; консюмер: notifications (уведомляет адресата) + push ContactRequestReceived в hub; контракт — ../events/contact-request-created.md |
ContactRequestAcceptedEvent | интеграционное, topic pin.messenger.contact-request-accepted.v1, ContractVersion = 1 | Pending → Accepted | ContactId, RequesterUserId, AddresseeUserId, RespondedAt, RespondedByUserId; консюмеры: notifications, разблокировка звонков (call-session.md) + push обеим сторонам; контракт — ../events/contact-request-accepted.md |
Доменные события поднимаются при переходах статуса; интеграционные события публикуются через outbox атомарно с переходом статуса (в одном согласованном изменении).
Индексы, хранение, безопасность
| Аспект | Значение |
|---|---|
| Индексы | PK; ux_contacts_active_pair unique partial (PairKey) where Status in ('Pending','Accepted'); ix_contacts_requester (RequesterUserId,Status); ix_contacts_addressee (AddresseeUserId,Status) |
| Партиционирование / retention | none (граф контактов, история хранится бессрочно) |
| Concurrency | оптимистическая блокировка: одновременные Accept / Cancel — проигравший получает конфликт и перечитывает состояние |
| Permissions / PII | messenger.contacts.manage. Видимость строки — только участникам пары (RequesterUserId / AddresseeUserId). PII: связь раскрывает полный профиль (ФИО + телефон) обеим сторонам только при Status = Accepted (INV-CT-5); до Accepted — маскированная проекция; в логи — только Id/PairKey-hash |
| Audit | отметки создания/изменения/удаления (кто и когда); факт ответа (RespondedAt, RespondedByUserId) иммутабелен |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (3): Entity (Replica): MessengerUserEntity, Entity / Aggregate: CallSessionEntity — сигналинг и лог звонка (media отложен), Entity / Aggregate: ChatEntity (+ParticipantEntity как owned-child)
- API (12): DELETE /api/messenger/blacklist/{blockedUserId} — Разблокировать пользователя, DELETE /api/messenger/contacts/{contactId} — Удалить контакт / отозвать исходящую заявку, GET /api/messenger/contacts — Список подтверждённых контактов, GET /api/messenger/contacts/requests — Заявки в контакты (входящие/исходящие), GET /api/messenger/contacts/search — Глобальный поиск пользователя по телефону, POST /api/messenger/blacklist — Заблокировать пользователя, POST /api/messenger/calls — Начать звонок, POST /api/messenger/chats/{chatId}/messages/text — Отправить текстовое сообщение, POST /api/messenger/contacts/requests — Отправить заявку в контакты, POST /api/messenger/contacts/requests/{contactId}/accept — Принять заявку в контакты, POST /api/messenger/contacts/requests/{contactId}/decline — Отклонить заявку в контакты, SignalR Hub /hubs/messenger — MessengerHub (realtime-контракт)
- Use Cases (2): UC-MSG-004. Заблокировать пользователя (чёрный список), UC-MSG-005. Сигналинг аудио/видео-звонка
- События (3): События сервиса messenger: реестр топиков, Event Contract: pin.messenger.contact-request-accepted (ContactRequestAcceptedEvent), Event Contract: pin.messenger.contact-request-created (ContactRequestCreatedEvent)
- Консюмеры (1): Consumer: Messenger User Replica Sync (MessengerUserUpsertedConsumer)
- Фоновые задачи (2): Background Job: CallTimeoutReaperJob, Background Job: RealtimeDispatchJob
- registries (1): Реестр сущностей PIN
Связанные документы
- messenger-user.md (справочник пользователей, ФИО/телефон, маскирование),
blacklist.md (guard
INV-CT-4), call-session.md (право звонкаINV-CT-5). - Контракты событий:
../events/contact-request-created.md,../events/contact-request-accepted.md.