Entity: ParticipantEntity (owned-child ChatEntity)
Дом полей сущности «Участник чата» контекста messenger — членство, роль и read-state пользователя в
конкретном чате (ADR-0055: собственный мини-мессенджер PIN). Все проекции (списки «мои чаты», счётчики
непрочитанных, read-receipt, payload realtime-сообщений) ссылаются сюда и описывают только дельту.
Дом самого чата — chat.md; дом сообщений — message.md (будущий путь по конвенции).
Назначение
ParticipantEntity — строка членства «пользователь ↔ чат» в отдельной таблице messenger.participants,
owned-child агрегата ChatEntity (жизненный цикл, транзакционная граница и concurrency —
в границах агрегата Chat). Держит: роль участника (Role), временные метки вступления/выхода
(JoinedAt/RemovedAt), read-state курсор (LastReadSeq/LastReadAt) для непрочитанных и read-receipt,
пер-участниковую тишину уведомлений (IsMuted). Активный участник — RemovedAt is null; удаление —
soft (история сохраняется для чата/аудита). Состав чата задаётся по типу чата: Direct — ровно 2 участника
Member; Lead-чат — синхронизируется консюмером pin.crm.lead-participants-changed
из состава LeadParticipantEntity (lead.md, INV-LEAD-7); Deal-чат —
консюмером pin.crm.deal-members-changed из состава
DealMemberEntity (deal.md, INV-DEAL-3). Оба процесса используют единый
ParticipantRole (ADR-0071). Отображаемые ФИО берутся из реплики pin.identity.users.v1 по UserId —
сама строка участника PII не хранит.
Классификация
| Поле | Значение |
|---|---|
| Контекст | messenger |
| Kind | Entity (owned-child агрегата Chat; отдельная таблица) |
| Source of truth | messenger (членство и read-state); ролевой состав Lead/Deal-чата — проекция crm (consumers lead-participants / deal-members); ФИО — реплика identity |
| Таблица | messenger.participants |
| Tenant isolation | Не применяется: messenger — кросс-тенантный платформенный сервис (1:1 чаты связывают пользователей разных компаний, Deal-чат — несколько компаний). Изоляция доступа — по членству (участник видит только свои чаты), не по TenantId. |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор строки членства (IEntity.Id, v7). |
ChatId | Guid | Да | FK fk_participants_chat_id → messenger.chats(id) cascade; часть unique ux_participants_chat_user; часть partial ix_participants_active | app / consumer | Родительский чат (chat.md). |
UserId | Guid | Да | index ix_participants_user_id; часть unique ux_participants_chat_user (ChatId,UserId); логическая ссылка identity.users(id) (без cross-service FK) | app / consumer | Пользователь-участник. Основа списков «мои чаты» и per-user unread. |
Role | ParticipantRole | Да | enum как text, varchar(32) | app / consumers lead-participants / deal-members | Роль участника в чате (см. enum). |
JoinedAt | DateTimeOffset | Да | — | app | Момент вступления/добавления (при реактивации обновляется). |
RemovedAt | DateTimeOffset? | Нет | часть partial ix_participants_active (ChatId) where RemovedAt is null | app / consumer | Момент выхода/удаления; активный участник — RemovedAt is null. |
RemovedByUserId | Guid? | Нет | логическая ссылка identity.users(id) | app / consumer | Кто удалил участие; null — сам вышел / система (авто-состав). |
LastReadSeq | long (bigint) | Да | default 0, >= 0 | app | Монотонный курсор прочитанного. unread = Chat.LastMessageSeq − LastReadSeq (Chat.LastMessageSeq — chat.md). |
LastReadAt | DateTimeOffset? | Нет | — | app | Момент последнего MarkChatRead — read-receipt «прочитано» в 1:1. |
IsMuted | bool | Да | default false | app | Пер-участниковая тишина: подавляет offline-push сервиса notifications. Пользовательская настройка (вкл/выкл), не расширяемое доменное состояние — bool оправдан. |
UpdatedAt | DateTimeOffset | Да | — | app | Момент последнего изменения. |
Enum ParticipantRole
Хранение — text (varchar(32)). Fallback для неизвестного значения при чтении реплики — сохранить
строку как есть + warning (bucket Guest при отображении); добавление значений — additive (без breaking).
Member — роль личных 1:1 чатов (messenger-owned); остальные — проекция ролевого состава группового чата
CRM-процесса (Lead/Deal), единый ParticipantRole из crm (ADR-0071). CRM-роли переносятся в чат
1:1 по значению (identity-маппинг, без трансляции).
| Значение | Применимо к | Описание |
|---|---|---|
Member | Direct | Личный участник 1:1-чата. |
Executor | Lead/Deal | Текущий исполнитель процесса (ведущий агент). |
Responsible | Lead/Deal | Ответственный/куратор ведения (опционален). |
CoAgent | Lead/Deal | Со-агент (нефинансовый). |
Assistant | Lead/Deal | Ассистент. |
Lawyer | Lead/Deal | Юрист сопровождения. |
InvestorPrimary | Lead/Deal | Основной инвестор. |
InvestorSecondary | Lead/Deal | Со-инвестор/созаёмщик (сколько угодно). |
SellManager | Deal | Менеджер продаж застройщика (только чат сделки). |
PinManager | Deal | Менеджер ПИН (сквозной участник чатов сделок). |
Guest | Lead/Deal | Fallback-bucket для неизвестной additive-роли из crm (строка сохраняется как есть + warning). |
Маппинг из crm ParticipantRole (consumers lead-participants / deal-members):
identity-маппинг «значение → то же значение» для всех CRM-ролей выше; неизвестное значение — сохранить строкой
как есть + warning (отображается как Guest), реконсиляция состава не ломается (fail-safe).
Legacy-значения (до ADR-0071, признаются на чтении исторических строк, новыми консюмерами не пишутся):
Investor (= InvestorPrimary), Agent (= Executor), DeveloperManager (= SellManager).
Value objects
Value objects не используются (все поля — скаляры / enum-строка).
Модели JSONB (versioned)
JSONB-модели не используются.
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|---|---|---|---|
ChatEntity (родитель) | owned (в агрегате) | ChatId | загружается в составе агрегата Chat по ChatId | cascade (удаление чата удаляет строки участников) |
UserEntity (identity) | replica display-полей | UserId (логическая) | по событию pin.identity.users.v1 | — |
MessageEntity | логическая (курсор) | LastReadSeq vs Chat.LastMessageSeq / Message.Seq | не FK; расчёт unread / read-receipt (message.md, будущий путь) | — |
ChatEventEntity | материализация доменных событий | ChatId | member-события → строки ленты чата (chat-event.md, будущий путь) | — |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
| INV-PART-1 | Уникальность активного (ChatId, UserId): повторное добавление реактивирует (RemovedAt = null) существующую строку или no-op, а не создаёт дубль | unique ux_participants_chat_user + Chat.AddParticipant(userId, role) | MESSENGER_PARTICIPANT_DUPLICATE |
| INV-PART-2 | LastReadSeq монотонен: MarkChatRead применяет max(current, requested), requested ≤ Chat.LastMessageSeq | Chat.MarkRead(userId, seq) (set-based GREATEST, монотонный guard) | MESSENGER_READ_SEQ_INVALID |
| INV-PART-3 | Состав по типу чата: Direct — ровно 2 активных Role = Member; Lead-чат — состав ⇔ активные LeadParticipant, Deal-чат — состав ⇔ активные DealMember (INV-CHAT-6/7, chat.md); число участников по ролям не ограничивается (несколько агентов/инвесторов допустимо) | фабрика/методы агрегата Chat + consumers lead-participants / deal-members | MESSENGER_CHAT_PARTICIPANTS_INVALID |
| INV-PART-4 | Удаление участия — soft (RemovedAt), строка сохраняется для истории чата/аудита; удалённый теряет доступ (INV-CHAT-4) и realtime-подписку chat:{ChatId} | Chat.RemoveParticipant(userId, actor) | — |
| INV-PART-5 | Read-receipt виден автору: в 1:1 «прочитано» ⇔ LastReadSeq собеседника ≥ Seq сообщения (+LastReadAt); в групповом Deal-чате — агрегированный счётчик прочитавших (без пофамильного раскрытия по умолчанию) | проекция read-receipt поверх LastReadSeq активных участников | — |
Конкурентность и граничные случаи
MarkChatReadпротив нового сообщения. ПродвижениеLastReadSeq— set-basedGREATEST(current, requested)без read-modify-write, поэтому гонка «отметить прочитанным» и «пришло новое сообщение» безопасна: параллельный инкрементChat.LastMessageSeqне откатывает курсор,unreadпересчитывается от актуальногоLastMessageSeq(INV-PART-2). Отдельной блокировки участник не берёт — конфликт с правкой родителя разрешается на границе агрегата Chat.- Граница курсора.
requestedвне диапазона (< 0или> Chat.LastMessageSeq) — отказMESSENGER_READ_SEQ_INVALID(INV-PART-2); в пределах, но≤ current— идемпотентный no-op (курсор монотонен, назад не двигается); повторMarkChatReadс тем жеSeqэффекта не даёт. - Повторное добавление в состав.
AddParticipantна уже активную пару(ChatId, UserId)— no-op; на ранее удалённую (RemovedAt != null) — реактивация той же строки (RemovedAt = null,JoinedAtобновлён), а не новая строка (INV-PART-1, uniqueux_participants_chat_user). Гонка двух добавлений упирается в unique-индекс и сводится к одной активной строке. - Отставшая реплика имени. Если событие
pin.identity.users.v1поUserIdещё не доехало, список участников отдаёт безопасный placeholder-подпись вместо ФИО (строка участника PII не хранит); подпись догоняет по мере обработки реплики (окно — секунды/минуты). Доступ по членству и read-state от наличия реплики не зависят.
Доменные и интеграционные события
Все события — доменные, поднимаются агрегатом Chat при переходах и обрабатываются в той же транзакции; persist-then-push (ADR-0055): сначала фиксация + материализация ленты, затем push в SignalR-hub. Собственных Kafka-событий участник не публикует; статус чата — зона ChatEntity.
| Событие | Тип | Когда | Побочный эффект / snapshot |
|---|---|---|---|
ParticipantAdded | доменное | новый/реактивированный активный участник | ChatEventEntity типа MemberAdded + push ChatUpdated в hub group chat:{ChatId}. Поля: ChatId, UserId, Role |
ParticipantRemoved | доменное | RemovedAt проставлен (soft) | ChatEventEntity типа MemberRemoved + push ChatUpdated. Поля: ChatId, UserId, RemovedByUserId |
RoleChanged | доменное | смена Role (только consumers lead-participants / deal-members) | ChatEventEntity типа RoleChanged + push ChatUpdated. Поля: ChatId, UserId, Role |
ReadStateChanged | доменное | LastReadSeq/LastReadAt продвинуты (MarkChatRead) | push ReadReceiptUpdated (eventName read_receipt_updated) в hub group chat:{ChatId}. Ленту чата не материализует. Поля: ChatId, UserId, LastReadSeq, LastReadAt |
Индексы, хранение, безопасность
| Аспект | Значение |
|---|---|
| Индексы | PK btree (id); ux_participants_chat_user unique (chat_id,user_id); ix_participants_user_id btree (user_id) — списки «мои чаты» + per-user unread; partial ix_participants_active (chat_id) where removed_at is null |
| Партиционирование / retention | none (десятки участников на чат, 1–10k активных — ADR-0056); строки не удаляются (soft-remove) |
| Concurrency | в границах агрегата Chat (оптимистическая блокировка родителя); read-state — set-based атомарный GREATEST(LastReadSeq, requested) без read-modify-write (INV-PART-2); отдельной блокировки у участника нет |
| Permissions / PII | Доступ к списку участников = членство в чате (участник видит соучастников своих чатов). Смену Role и состав группового чата выполняют только консюмеры lead-participants-changed (Lead-чат) / deal-members-changed (Deal-чат) — пользовательских ручек смены ролей/состава нет. Строка PII не хранит (UserId-ссылка, не ФИО/телефон); ФИО резолвятся из реплики identity. В 1:1 read-receipt раскрывается пофамильно (2 участника); в групповом Deal-чате — агрегированный счётчик (INV-PART-5) |
| Audit | soft-remove (RemovedAt/RemovedByUserId) + лента чата (ChatEventEntity) — бизнес-аудит состава; отдельной Audit-сущности нет |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (3): Entity (Replica): MessengerUserEntity, Entity / Aggregate: ChatEntity (+ParticipantEntity как owned-child), Entity: CallParticipantEntity (owned-child ConferenceSessionEntity)
- API (11): DELETE /api/messenger/chats/{chatId}/messages/{messageId} — Удаление сообщения (мягкое, ≤ 1 мин), GET /api/messenger/admin/chats/{chatId}/transcript — Выгрузка переписки для арбитража, GET /api/messenger/chats — Список чатов пользователя, GET /api/messenger/chats/{chatId} — Карточка чата (участники + read-state), GET /api/messenger/chats/{chatId}/messages — История чата (keyset-пагинация), PATCH /api/messenger/chats/{chatId}/messages/{messageId} — Правка сообщения (≤ 1 мин), POST /api/messenger/chats/{chatId}/messages/attachment — Отправить вложение, POST /api/messenger/chats/{chatId}/messages/text — Отправить текстовое сообщение, POST /api/messenger/chats/{chatId}/read — Отметить чат прочитанным, POST /api/messenger/chats/direct — Открыть/создать личный 1:1 чат, SignalR Hub /hubs/messenger — MessengerHub (realtime-контракт)
- Use Cases (3): UC-MSG-001. Открыть личный чат по поиску пользователя по телефону, UC-MSG-002. Авто-провизия чата сделки по событию CRM, UC-MSG-008. Добавленный участник появляется в чате сделки/лида
- События (1): Event Contract: pin.messenger.message-posted (MessagePostedEvent)
- Консюмеры (4): Consumer: Deal Chat Members Sync (MessengerDealMembersChangedConsumer), Consumer: Deal Chat Provisioning (MessengerDealCreatedConsumer), Consumer: Lead Chat Participants Sync (MessengerLeadParticipantsChangedConsumer), Consumer: Messenger User Replica Sync (MessengerUserUpsertedConsumer)
- Фоновые задачи (2): Background Job: ChatProvisionRetryJob, Background Job: RealtimeDispatchJob
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
Связанные документы
- Родитель-агрегат (дом чата,
LastMessageSeq, INV-CHAT-4/6/7): chat.md; лента: chat-event.md, сообщения: message.md (будущие пути по конвенции); карта контекста: ../README.md. - Источник ролевого состава групповых чатов: crm/events/lead-participants-changed.md
(Lead), crm/events/deal-members-changed.md (Deal); дом единого
ParticipantRole: crm/domain/lead.md, crm/domain/deal.md. - Консюмеры синка состава: consumers/lead-participants.md, consumers/deal-members-changed.md; решение об единой модели участников — ADR-0071.
- Реплика имён участников: identity/events/user-upserted.md, identity/domain/user.md.
- Решение: ADR-0055 (свой мини-мессенджер).