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

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
KindEntity (owned-child агрегата Chat; отдельная таблица)
Source of truthmessenger (членство и read-state); ролевой состав Lead/Deal-чата — проекция crm (consumers lead-participants / deal-members); ФИО — реплика identity
Таблицаmessenger.participants
Tenant isolationНе применяется: messenger — кросс-тенантный платформенный сервис (1:1 чаты связывают пользователей разных компаний, Deal-чат — несколько компаний). Изоляция доступа — по членству (участник видит только свои чаты), не по TenantId.

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappИдентификатор строки членства (IEntity.Id, v7).
ChatIdGuidДаFK fk_participants_chat_id → messenger.chats(id) cascade; часть unique ux_participants_chat_user; часть partial ix_participants_activeapp / consumerРодительский чат (chat.md).
UserIdGuidДаindex ix_participants_user_id; часть unique ux_participants_chat_user (ChatId,UserId); логическая ссылка identity.users(id) (без cross-service FK)app / consumerПользователь-участник. Основа списков «мои чаты» и per-user unread.
RoleParticipantRoleДаenum как text, varchar(32)app / consumers lead-participants / deal-membersРоль участника в чате (см. enum).
JoinedAtDateTimeOffsetДаappМомент вступления/добавления (при реактивации обновляется).
RemovedAtDateTimeOffset?Нетчасть partial ix_participants_active (ChatId) where RemovedAt is nullapp / consumerМомент выхода/удаления; активный участник — RemovedAt is null.
RemovedByUserIdGuid?Нетлогическая ссылка identity.users(id)app / consumerКто удалил участие; null — сам вышел / система (авто-состав).
LastReadSeqlong (bigint)Даdefault 0, >= 0appМонотонный курсор прочитанного. unread = Chat.LastMessageSeq − LastReadSeq (Chat.LastMessageSeqchat.md).
LastReadAtDateTimeOffset?НетappМомент последнего MarkChatRead — read-receipt «прочитано» в 1:1.
IsMutedboolДаdefault falseappПер-участниковая тишина: подавляет offline-push сервиса notifications. Пользовательская настройка (вкл/выкл), не расширяемое доменное состояние — bool оправдан.
UpdatedAtDateTimeOffsetДа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-маппинг, без трансляции).

ЗначениеПрименимо кОписание
MemberDirectЛичный участник 1:1-чата.
ExecutorLead/DealТекущий исполнитель процесса (ведущий агент).
ResponsibleLead/DealОтветственный/куратор ведения (опционален).
CoAgentLead/DealСо-агент (нефинансовый).
AssistantLead/DealАссистент.
LawyerLead/DealЮрист сопровождения.
InvestorPrimaryLead/DealОсновной инвестор.
InvestorSecondaryLead/DealСо-инвестор/созаёмщик (сколько угодно).
SellManagerDealМенеджер продаж застройщика (только чат сделки).
PinManagerDealМенеджер ПИН (сквозной участник чатов сделок).
GuestLead/DealFallback-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 по ChatIdcascade (удаление чата удаляет строки участников)
UserEntity (identity)replica display-полейUserId (логическая)по событию pin.identity.users.v1
MessageEntityлогическая (курсор)LastReadSeq vs Chat.LastMessageSeq / Message.Seqне FK; расчёт unread / read-receipt (message.md, будущий путь)
ChatEventEntityматериализация доменных событийChatIdmember-события → строки ленты чата (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-2LastReadSeq монотонен: MarkChatRead применяет max(current, requested), requested ≤ Chat.LastMessageSeqChat.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-membersMESSENGER_CHAT_PARTICIPANTS_INVALID
INV-PART-4Удаление участия — soft (RemovedAt), строка сохраняется для истории чата/аудита; удалённый теряет доступ (INV-CHAT-4) и realtime-подписку chat:{ChatId}Chat.RemoveParticipant(userId, actor)
INV-PART-5Read-receipt виден автору: в 1:1 «прочитано» ⇔ LastReadSeq собеседника ≥ Seq сообщения (+LastReadAt); в групповом Deal-чате — агрегированный счётчик прочитавших (без пофамильного раскрытия по умолчанию)проекция read-receipt поверх LastReadSeq активных участников

Конкурентность и граничные случаи

  • MarkChatRead против нового сообщения. Продвижение LastReadSeq — set-based GREATEST(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, unique ux_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
Партиционирование / retentionnone (десятки участников на чат, 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)
Auditsoft-remove (RemovedAt/RemovedByUserId) + лента чата (ChatEventEntity) — бизнес-аудит состава; отдельной Audit-сущности нет

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

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

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