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 |
| Kind | AggregateRoot (дочерняя ParticipantEntity — Entity внутри агрегата, см. participant.md) |
| Source of truth | messenger (переписка); LeadId/DealId/ProjectId/DealTenantId — реплики из crm |
| Таблица | messenger.chats (+ messenger.participants) |
| Tenant isolation | Нет (не tenanted строково): Direct кросс-сторонний; доступ = членство активного Participant, а не TenantId. DealTenantId — только scope арбитража, не фильтр строк (см. Безопасность). |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор чата. |
Kind | ChatKind | Да | дискриминатор, enum (text), varchar(16), index ix_chats_kind | app | Direct / Lead / Deal (см. enum). |
Title | string | Да | varchar(256) | app / event | Для Deal — из события pin.crm.deal-created.v1 («Объект №…, ЖК …»). Для Lead — из провизии Lead-чата (например «Лид: <клиент>»), PII в колонке не оседает (резолв на чтении). Для Direct — хранит служебный placeholder; отображаемый заголовок резолвится на чтении из имени собеседника (per-reader, каждая сторона видит имя другой), PII в колонке не оседает. |
Status | ChatStatus | Да | enum (text), varchar(16), default Active | app / consumer | Статус чата (см. enum). |
DirectKey | string? | Нет (для Direct — да) | varchar(128); unique ux_chats_direct_key where Kind = 'Direct'; null для Deal | app | Детерминированный ключ неупорядоченной пары: min(userA,userB) + ':' + max(userA,userB) (сравнение по Guid). Основа идемпотентности OpenDirectChat (INV-CHAT-1). |
LeadId | Guid? | Нет (для Lead — да) | unique ux_chats_lead_id where lead_id is not null; логическая ссылка на crm.leads(id); null кроме Lead | event | Лид Lead-чата (INV-CHAT-2L). |
DealId | Guid? | Нет (для Deal — да) | unique ux_chats_deal_id where deal_id is not null; логическая ссылка на crm.deals(id); null кроме Deal | event | Сделка Deal-чата (INV-CHAT-2). |
DealTenantId | Guid? | Нет | index ix_chats_deal_tenant | event | Компания-владелец процесса: DeveloperCompanyId сделки (Deal) / AgencyCompanyId агентства-владельца лида (Lead); scope арбитража (messenger.admin.view), не фильтр строк. null для Direct. |
ProjectId | Guid? | Нет | — | event (реплика) | Проект сделки из deal-created (навигация/группировка Deal-чатов); null для Lead/Direct (у лида объект ещё не выбран). |
LeadMemberVersion | long? | Нет | — | consumer | Монотонный guard применённых снапшотов состава лида (pin.crm.lead-participants-changed.v1); null до первого применённого события. См. consumers/lead-participants.md. |
DealMemberVersion | long? | Нет | — | consumer | Монотонный guard применённых снапшотов состава сделки (pin.crm.deal-members-changed.v1); null до первого применённого события. См. consumers/deal-members-changed.md. |
LastMessageId | Guid? | Нет | логическая ссылка на messenger.messages(id) | app | Последнее сообщение чата (денорм для списка). |
LastMessageSeq | long | Да | bigint, default 0 | app | Seq последнего сообщения — основа unread (сравнение с Participant.LastReadSeq). |
LastMessageAt | DateTimeOffset? | Нет | index ix_chats_last_message_at desc | app | Время последнего сообщения — сортировка списка чатов. |
LastMessagePreview | string? | Нет | varchar(256) | app | Денорм короткое превью последнего сообщения (без тела вложения, обрезка до 256). |
LastSeqCounter | long | Да | bigint, default 0 | app | Монотонный счётчик Seq сообщений чата; инкремент атомарно при отправке (INV-CHAT-5). Авторитет нумерации; LastMessageSeq — его денорм-снимок последнего поста. |
CreatedAt | DateTimeOffset | Да | index ix_chats_created_at | app | Момент создания. |
CreatedByUserId | Guid? | Нет | — | app | Инициатор (Direct — открывший; Lead/Deal — null, создан консюмером). |
UpdatedAt | DateTimeOffset | Да | — | app | Момент последнего изменения. |
UpdatedByUserId | Guid? | Нет | — | app | Последний изменивший. |
DeletedAt | DateTimeOffset? | Нет | — | app | Soft-delete (административно). |
DeletedByUserId | Guid? | Нет | — | 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 |
|---|---|---|---|---|
ParticipantEntity | owned-child | participants.chat_id | include при карточке; состав чата | cascade |
MessageEntity | reference (children) | messages.chat_id | пагинированная лента / последнее сообщение | restrict |
ChatEventEntity | reference (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-1 | Direct уникален на неупорядоченную пару (DirectKey unique); OpenDirectChat идемпотентен — повтор возвращает существующий чат | Chat.OpenDirect(userA, userB) + ux_chats_direct_key | — (идемпотентно) |
| INV-CHAT-2 | Deal-чат уникален на DealId; провизия из deal-created идемпотентна (повторная доставка не плодит чат) | консюмер провизии Deal-чата + ux_chats_deal_id | — (идемпотентно) |
| INV-CHAT-2L | Lead-чат уникален на LeadId; провизия Lead-чата идемпотентна (повторная доставка не плодит чат) | консюмер провизии Lead-чата + ux_chats_lead_id | — (идемпотентно) |
| INV-CHAT-3 | Писать в чат = CommunicationPolicy.CanMessage (нет блокировки ни одной стороной); звонить = accepted Contact | policy при 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/Preview | Chat.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-7 | Direct — ровно 2 активных участника роли Member; Lead/Deal — ≥ 1 активный участник. Число участников по ролям не ограничивается (допустимо несколько агентов — Executor + CoAgent/Assistant/Lawyer — и несколько инвесторов — InvestorPrimary + InvestorSecondary); ролевой состав валидирует crm (не messenger), messenger приводит комнату к присланному активному составу | Chat.OpenDirect / синк состава по ActiveParticipants/ActiveMembers | MSG_CHAT_PARTICIPANTS_INVALID |
| INV-CHAT-8 | Closed-чат: новые сообщения пользователей запрещены; системные карточки от консюмеров допустимы | guard PostMessage (actor ≠ система) | MSG_CHAT_CLOSED |
Готовность провизии Deal-чата (Provisioning/Ready/Failed) — не статус чата, а проекция на стороне
crm (Deal.ChatStatus), транслируемая из хода консюмера (создан ChatId → Ready); сам чат при этом
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; консюмер crm → Deal.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 |
| Партиционирование / retention | none (ADR-0056; чат — низкообъёмная сущность, пересмотр при росте) |
| Concurrency | оптимистическая блокировка (правка RegisterPosted/переименования не должна затирать чужую); Seq выдаётся атомарно на строке чата для монотонности |
| Permissions / PII | messenger.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) |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (4): Entity / Aggregate: ChatEventEntity, Entity / Aggregate: ConferenceSessionEntity — групповой звонок в чате сделки, Entity / Aggregate: MessageEntity (TPH: Text/Attachment/EventCard), Entity: ParticipantEntity (owned-child ChatEntity)
- 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 (5): UC-MSG-001. Открыть личный чат по поиску пользователя по телефону, UC-MSG-002. Авто-провизия чата сделки по событию CRM, UC-MSG-003. Правка сообщения в окне ≤ 60 секунд, UC-MSG-006. Групповой звонок-конференция в чате сделки, UC-MSG-008. Добавленный участник появляется в чате сделки/лида
- События (3): События сервиса messenger: реестр топиков, Event Contract: pin.messenger.deal-chat-status-changed (DealChatStatusChangedEvent), Event Contract: pin.messenger.message-posted (MessagePostedEvent)
- Консюмеры (7): Consumer: Booking Expired Card (MessengerBookingExpiredConsumer), Consumer: Booking Paid Card (MessengerBookingPaidConsumer), Consumer: Deal Chat Members Sync (MessengerDealMembersChangedConsumer), Consumer: Deal Chat Provisioning (MessengerDealCreatedConsumer), Consumer: Deal Stage Card (MessengerDealStageChangedConsumer), Consumer: Document Request Card (MessengerDocumentRequestedConsumer), Consumer: Lead Chat Participants Sync (MessengerLeadParticipantsChangedConsumer)
- Фоновые задачи (1): Background Job: ChatProvisionRetryJob
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
Связанные документы
- Дочерний дом: participant.md; сообщения — message.md; коммуникационные политики — contact.md, blacklist.md.
- Событие проекции статуса: ../events/deal-chat-status-changed.md; источник провизии — deal-created.md; сделка (дом) — deal.md.
- Синк состава групповых чатов: consumers/deal-members-changed.md (Deal), consumers/lead-participants.md (Lead); лид (дом) — lead.md; событие состава лида — lead-participants-changed.md.
- ADR: 0055 мини-мессенджер, 0052 tenancy, 0056 simplicity-first.