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

Entity / Aggregate: ChatEntity (+ParticipantEntity как owned-child)

Дом полей агрегата «Чат» контекста messenger (ADR-0055 — собственный мини-мессенджер PIN). Все проекции (список чатов, карточка чата, unread-счётчики, payload событий, search-документы) ссылаются сюда и описывают только дельту. Дочерний ParticipantEntity — свой дом participant.md; здесь он фигурирует как owned-коллекция агрегата.

Назначение

ChatEntity — корень переписки: либо личный Direct (1:1 между двумя пользователями любых сторон), либо групповой чат CRM-процесса — Lead-чат лида (lead.md, «до сделки») или Deal-чат сделки (deal.md, ADR-0055 — оркестрация чат↔процесс через события CRM). Агрегат владеет составом участников (participant.md) и денормализует last-message для списка чатов и unread. Сообщения (message.md) — отдельная сущность в границе чата; тело сообщения здесь не хранится, только короткое превью. Source of truth переписки — messenger; готовность провизии Deal-чата — проекция на стороне crm (Deal.ChatStatus), в самом чате отдельным полем не хранится. Состав Lead/Deal-чата — производный от crm (зеркалит активных участников процесса); пользовательских ручек управления участниками группового чата нет.

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

ПолеЗначение
Контекстmessenger
KindAggregateRoot (дочерняя ParticipantEntity — Entity внутри агрегата, см. participant.md)
Source of truthmessenger (переписка); LeadId/DealId/ProjectId/DealTenantId — реплики из crm
Таблицаmessenger.chats (+ messenger.participants)
Tenant isolationНет (не tenanted строково): Direct кросс-сторонний; доступ = членство активного Participant, а не TenantId. DealTenantId — только scope арбитража, не фильтр строк (см. Безопасность).

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappИдентификатор чата.
KindChatKindДадискриминатор, enum (text), varchar(16), index ix_chats_kindappDirect / Lead / Deal (см. enum).
TitlestringДаvarchar(256)app / eventДля Deal — из события pin.crm.deal-created.v1 («Объект №…, ЖК …»). Для Lead — из провизии Lead-чата (например «Лид: <клиент>»), PII в колонке не оседает (резолв на чтении). Для Direct — хранит служебный placeholder; отображаемый заголовок резолвится на чтении из имени собеседника (per-reader, каждая сторона видит имя другой), PII в колонке не оседает.
StatusChatStatusДаenum (text), varchar(16), default Activeapp / consumerСтатус чата (см. enum).
DirectKeystring?Нет (для Direct — да)varchar(128); unique ux_chats_direct_key where Kind = 'Direct'; null для DealappДетерминированный ключ неупорядоченной пары: min(userA,userB) + ':' + max(userA,userB) (сравнение по Guid). Основа идемпотентности OpenDirectChat (INV-CHAT-1).
LeadIdGuid?Нет (для Lead — да)unique ux_chats_lead_id where lead_id is not null; логическая ссылка на crm.leads(id); null кроме LeadeventЛид Lead-чата (INV-CHAT-2L).
DealIdGuid?Нет (для Deal — да)unique ux_chats_deal_id where deal_id is not null; логическая ссылка на crm.deals(id); null кроме DealeventСделка Deal-чата (INV-CHAT-2).
DealTenantIdGuid?Нетindex ix_chats_deal_tenanteventКомпания-владелец процесса: DeveloperCompanyId сделки (Deal) / AgencyCompanyId агентства-владельца лида (Lead); scope арбитража (messenger.admin.view), не фильтр строк. null для Direct.
ProjectIdGuid?Нетevent (реплика)Проект сделки из deal-created (навигация/группировка Deal-чатов); null для Lead/Direct (у лида объект ещё не выбран).
LeadMemberVersionlong?НетconsumerМонотонный guard применённых снапшотов состава лида (pin.crm.lead-participants-changed.v1); null до первого применённого события. См. consumers/lead-participants.md.
DealMemberVersionlong?НетconsumerМонотонный guard применённых снапшотов состава сделки (pin.crm.deal-members-changed.v1); null до первого применённого события. См. consumers/deal-members-changed.md.
LastMessageIdGuid?Нетлогическая ссылка на messenger.messages(id)appПоследнее сообщение чата (денорм для списка).
LastMessageSeqlongДаbigint, default 0appSeq последнего сообщения — основа unread (сравнение с Participant.LastReadSeq).
LastMessageAtDateTimeOffset?Нетindex ix_chats_last_message_at descappВремя последнего сообщения — сортировка списка чатов.
LastMessagePreviewstring?Нетvarchar(256)appДенорм короткое превью последнего сообщения (без тела вложения, обрезка до 256).
LastSeqCounterlongДаbigint, default 0appМонотонный счётчик Seq сообщений чата; инкремент атомарно при отправке (INV-CHAT-5). Авторитет нумерации; LastMessageSeq — его денорм-снимок последнего поста.
CreatedAtDateTimeOffsetДаindex ix_chats_created_atappМомент создания.
CreatedByUserIdGuid?НетappИнициатор (Direct — открывший; Lead/Dealnull, создан консюмером).
UpdatedAtDateTimeOffsetДаappМомент последнего изменения.
UpdatedByUserIdGuid?НетappПоследний изменивший.
DeletedAtDateTimeOffset?НетappSoft-delete (административно).
DeletedByUserIdGuid?НетappКто удалил.

Enum ChatKind

ЗначениеОписание
DirectЛичный 1:1-чат между двумя пользователями любых сторон (кросс-tenant).
LeadГрупповой чат лида (до сделки); состав зеркалит активных участников лида LeadParticipant (INV-CHAT-6).
DealГрупповой чат сделки; состав зеркалит активных участников сделки DealMember (INV-CHAT-6).

Enum ChatStatus

ЗначениеОписание
ActiveРабочее состояние (default).
ArchivedАдминистративное скрытие на уровне чата (редко). Пользовательское «скрыть у себя» — на Participant, не здесь (participant.md).
ClosedТерминально: процесс завершён (сделка Completed/Cancelled; лид Archived/Expired). Новые сообщения пользователей запрещены (INV-CHAT-8).

Value objects

Value objects не используются. Owned-коллекция агрегата — дочерний ParticipantEntity (таблица messenger.participants, cascade); его дом полей — participant.md.

Модели JSONB

JSONB-модели не используются.

Связи

СвязьТипКлючПравило загрузкиDelete behavior
ParticipantEntityowned-childparticipants.chat_idinclude при карточке; состав чатаcascade
MessageEntityreference (children)messages.chat_idпагинированная лента / последнее сообщениеrestrict
ChatEventEntityreference (children)chat_events.chat_idсистемные карточки/провижинингrestrict
LeadEntity (crm)внешняя логическаяLeadId / DealTenantId (AgencyCompanyId)реплика из провизии Lead-чата / pin.crm.lead-participants-changed.v1
DealEntity (crm)внешняя логическаяDealId / DealTenantId / ProjectIdреплика из pin.crm.deal-created.v1

Инварианты и переходы состояний

ИнвариантУсловиеГде проверяетсяОшибка
INV-CHAT-1Direct уникален на неупорядоченную пару (DirectKey unique); OpenDirectChat идемпотентен — повтор возвращает существующий чатChat.OpenDirect(userA, userB) + ux_chats_direct_key— (идемпотентно)
INV-CHAT-2Deal-чат уникален на DealId; провизия из deal-created идемпотентна (повторная доставка не плодит чат)консюмер провизии Deal-чата + ux_chats_deal_id— (идемпотентно)
INV-CHAT-2LLead-чат уникален на LeadId; провизия Lead-чата идемпотентна (повторная доставка не плодит чат)консюмер провизии Lead-чата + ux_chats_lead_id— (идемпотентно)
INV-CHAT-3Писать в чат = CommunicationPolicy.CanMessage (нет блокировки ни одной стороной); звонить = accepted Contactpolicy при PostMessage (см. contact.md, blacklist.md)MSG_COMMUNICATION_BLOCKED
INV-CHAT-4Доступ (чтение/запись) = наличие активного Participant (RemovedAt is null, UserId = actor); tenant-фильтра нетguard доступа всех операций чатаMSG_CHAT_ACCESS_DENIED
INV-CHAT-5При каждом посте атомарно: Seq = ++LastSeqCounter, обновляются LastMessageId/Seq/At/PreviewChat.RegisterPosted(message) (атомарно на строке чата)
INV-CHAT-6Состав Lead/Deal-чата зеркалит активных участников процесса в crm (единый ParticipantRole, ADR-0071): Lead — активные LeadParticipant, Deal — активные DealMember; синк консюмерами, пользовательских ручек управления составом группового чата нетконсюмеры pin.crm.lead-participants-changed.v1 / pin.crm.deal-members-changed.v1
INV-CHAT-7Direct — ровно 2 активных участника роли Member; Lead/Deal — ≥ 1 активный участник. Число участников по ролям не ограничивается (допустимо несколько агентов — Executor + CoAgent/Assistant/Lawyer — и несколько инвесторов — InvestorPrimary + InvestorSecondary); ролевой состав валидирует crm (не messenger), messenger приводит комнату к присланному активному составуChat.OpenDirect / синк состава по ActiveParticipants/ActiveMembersMSG_CHAT_PARTICIPANTS_INVALID
INV-CHAT-8Closed-чат: новые сообщения пользователей запрещены; системные карточки от консюмеров допустимыguard PostMessage (actor ≠ система)MSG_CHAT_CLOSED

Готовность провизии Deal-чата (Provisioning/Ready/Failed) — не статус чата, а проекция на стороне crm (Deal.ChatStatus), транслируемая из хода консюмера (создан ChatIdReady); сам чат при этом Active.

Конкурентность и порядок событий. Гонка двух OpenDirectChat на одну пару разрешается уникальным DirectKey: победитель создаёт чат, проигравший получает существующий (идемпотентно, INV-CHAT-1) — дубля не возникает. CRM-события состава могут прийти раньше провизии: deal-members-changed до deal-created — синхронизация состава ретраится, пока Deal-чат не создан (окно секунды–минуты); до этого пользователь видит сделку без доступного чата (Deal.ChatStatus = Provisioning на стороне crm). После создания чат Active, состав досинхронизируется до активных DealMember (INV-CHAT-6), участникам приходит ChatUpdated. Одновременные посты в один чат конкурируют за Seq: он выдаётся атомарно на строке чата (INV-CHAT-5), порядок ленты монотонен.

Доменные и интеграционные события

СобытиеТипКогдаSnapshot/поля
ChatCreatedдоменноефабрики OpenDirect / провизия Deal-чатаподписчик пишет ChatEventEntity типа ChatCreated + инициирует provisioning состава
MessagePosted-эффектдоменное (от message.md)Chat.RegisterPostedденорм last-message (INV-CHAT-5) + push в SignalR-hub group chat:{ChatId} (ADR-0055)
DealChatStatusChangedEventинтеграционное, topic pin.messenger.deal-chat-status-changed.v1, ContractVersion = 1консюмер провизии Deal-чатаDealId, ChatId?, Status (Provisioning/Ready/Failed), OccurredAt; консюмер crmDeal.ChatStatus; контракт — ../events/deal-chat-status-changed.md

Интеграционное DealChatStatusChangedEvent публикуется атомарно со сменой состояния агрегата.

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

АспектЗначение
ИндексыPK; ux_chats_direct_key (unique where kind = 'Direct'); ux_chats_lead_id (unique where lead_id is not null); ux_chats_deal_id (unique where deal_id is not null); ix_chats_deal_tenant (deal_tenant_id); ix_chats_last_message_at desc — сортировка списка; ix_chats_kind; ix_chats_created_at
Партиционирование / retentionnone (ADR-0056; чат — низкообъёмная сущность, пересмотр при росте)
Concurrencyоптимистическая блокировка (правка RegisterPosted/переименования не должна затирать чужую); Seq выдаётся атомарно на строке чата для монотонности
Permissions / PIImessenger.chats.view / messenger.chats.write — видимость = членство (активный Participant), не tenant. Арбитраж — messenger.admin.view, scope = DealTenantId (застройщик своих Deal-чатов / агентство своих Lead-чатов). Title Direct содержит имя собеседника: резолвится на чтении per-reader, телефон/PII в колонке не хранится; в логах имена не пишутся.
Auditотметки создания/изменения/удаления + soft-delete; системная история переписки — ChatEventEntity (append-only)

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

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

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