Entity / Aggregate: MessageEntity (TPH: Text/Attachment/EventCard)
Дом полей агрегата «Сообщение» контекста messenger (greenfield, ADR-0055).
Одна лента чата = поток MessageEntity. Все проекции (list-messages, unread, push в hub, payload в
notifications) ссылаются сюда и описывают дельту; дочерние MessageEditEntity и
EventCardRecipientEntity документируются здесь как часть агрегата.
Назначение
MessageEntity — единица переписки участников чата сделки: пользовательский текст, вложение или системная
event-карточка (событие сделки из crm). TPH по дискриминатору Kind — три подтипа в таблице
messenger.messages. Собственный агрегат: ссылается на чат через ChatId, но не грузится вместе с чатом
— высокообъёмная сущность, читается пагинированно по Seq (per-chat монотонный из Chat.LastSeqCounter,
chat.md). Event-карточки материализуют консюмеры Kafka-событий crm идемпотентно; пользователь
их не правит. Бинарь вложений — во внешнем storage/CDN, в messenger только FileId + метаданные.
Классификация
| Поле | Значение |
|---|---|
| Контекст | messenger |
| Kind | AggregateRoot — собственный агрегат; ссылается ChatId, не грузится с чатом (высокообъёмная лента). Дочерние MessageEditEntity/EventCardRecipientEntity — Entity внутри агрегата |
| Source of truth | messenger (метаданные + контент); бинарь вложений — storage/CDN |
| Таблица | messenger.messages (TPH, дискриминатор Kind) |
| Tenant isolation | Столбца tenant на сообщении нет — граница наследуется от чата (ChatId → Chat.TenantId); видимость — членство в чате (record-level) |
Поля (базовая часть TPH — все подтипы)
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app (v7) | Идентификатор сообщения. |
ChatId | Guid | Да | FK fk_messages_chat_id → messenger.chats(id) cascade, index ix_messages_chat_id | app | Чат-лента (chat.md). |
Seq | long | Да | bigint; unique ux_messages_chat_seq (ChatId,Seq) | Chat.LastSeqCounter | Per-chat монотонный порядковый — основа порядка ленты и unread. |
Kind | MessageKind | Да | TPH discriminator, .HasConversion<string>(), varchar(24), index (ChatId,Kind) | app | Text / Attachment / EventCard. |
SenderUserId | Guid? | Нет | index ix_messages_sender | app | Автор; null — система/карточка. |
SenderRole | ParticipantRole? | Нет | .HasConversion<string>(); enum — chat.md | app | Роль автора на момент отправки. |
IsSystem | bool | Да | default false | app | true — EventCard/системное. |
SentAt | DateTimeOffset | Да | — | app | Момент отправки. |
EditableUntil | DateTimeOffset? | Нет | = SentAt + 60s; только user-сообщения (IsSystem = false) | app | Граница окна правки/удаления (INV-MSG-1). |
EditedAt | DateTimeOffset? | Нет | столбец updated_at | app | Момент последней правки; null до правки. |
DeletedAt | DateTimeOffset? | Нет | soft-delete | app | Момент удаления (тело скрыто). |
IpAddress | IpAddress? | Нет | inet; только user-сообщения | app | IP отправителя. Sensitive/internal. |
UserAgent | string? | Нет | varchar(512); только user-сообщения | app | UA отправителя. Sensitive/internal. |
CreatedAt | DateTimeOffset | Да | — | app | Запись строки. |
Подтип TextMessageEntity (Kind = Text)
| Поле | Тип | Обязательность | Ограничения | Описание |
|---|---|---|---|---|
Body | string | Да | text, длина 1..8000 | Текст сообщения. PII. |
Подтип AttachmentMessageEntity (Kind = Attachment)
| Поле | Тип | Обязательность | Ограничения | Описание |
|---|---|---|---|---|
FileId | Guid | Да | ссылка на storage/CDN | Файл; бинарь не хранится (INV-MSG-7). |
FileName | string | Да | varchar(256) | Оригинальное имя файла. |
ContentType | string | Да | varchar(128) | MIME-тип. |
SizeBytes | long | Да | > 0 and <= <limit> (конфигурация) | Размер файла. |
Caption | string? | Нет | varchar(2000) | Подпись к вложению. PII. |
AttachmentKind | AttachmentType | Да | .HasConversion<string>(), varchar(16) | Класс вложения, выводится из ContentType. |
Загрузка — вне форм: клиент получает presign у storage, отправляет только FileId + метаданные.
Подтип EventCardMessageEntity (Kind = EventCard, typed jsonb Payload)
| Поле | Тип | Обязательность | Ограничения | Описание |
|---|---|---|---|---|
CardType | EventCardType | Да | .HasConversion<string>(), varchar(48) | Тип карточки (см. enum). |
Category | EventCardCategory | Да | .HasConversion<string>(), varchar(16) | TargetAction / Info. |
ActionStatus | EventCardActionStatus | Да | .HasConversion<string>(), varchar(16); для TargetAction init Pending | Состояние целевого действия. |
DefaultText | string | Да | varchar(1000); или i18n-ключ SEED-каталога | Текст карточки по умолчанию. |
DealId | Guid? | Нет | логическая ссылка на crm | Корреляция чат ↔ сделка. |
SourceEventId | Guid | Да | ux ux_messages_card_source (ChatId,CardType,SourceEventId) | = EventId Kafka-события crm (идемпотентность, INV-MSG-8). |
Payload | typed jsonb | Да | jsonb, ContractVersion = 1; модели ниже | Типизированные данные карточки. |
EventCard: SenderUserId = null, IsSystem = true, без IpAddress/UserAgent (INV-MSG-4).
Enum'ы
MessageKind { Text, Attachment, EventCard }(TPH-дискриминатор);AttachmentType { Image, Document, Audio, Video, Other }.EventCardCategory { TargetAction, Info }(целевое действие / информационная);EventCardActionStatus { None, Pending, Completed }.
Enum EventCardType (источник Kafka → Category → payload)
Каталог текстов карточек — SEED в Migrator (i18n-ключи). Повтор доставки не дублирует карточку (INV-MSG-8).
EventCardType | Category | Init ActionStatus | Источник (topic crm) | Payload-модель |
|---|---|---|---|---|
UploadBookingDocuments | TargetAction | Pending | pin.crm.document-requested.v1 | CardUploadDocumentsPayload |
UploadContractDocuments | TargetAction | Pending | pin.crm.document-requested.v1 | CardUploadDocumentsPayload |
AttachPaymentProof | TargetAction | Pending | pin.crm.document-requested.v1 | CardUploadDocumentsPayload |
BookingExpired | TargetAction | Pending | pin.crm.booking-expired.v1 | CardBookingExpiredPayload |
DealCreated | Info | None | pin.crm.deal-created.v1 | CardDealRefPayload |
StageChanged | Info | None | pin.crm.deal-stage-changed.v1 | CardStageChangedPayload |
BookingConfirmed | Info | None | pin.crm.booking-confirmed.v1 | CardDealRefPayload |
BookingContractPaid | Info | None | pin.crm.booking-paid.v1 | CardBookingPaidPayload |
SentToRosreestr | Info | None | pin.crm.deal-stage-changed.v1 | CardDealRefPayload |
DealCompleted | Info | None | pin.crm.deal-completed.v1 | CardDealRefPayload |
DocumentAccepted | Info | None | pin.crm.document-status-changed.v1 | CardDealRefPayload |
DocumentRejected | Info | None | pin.crm.document-status-changed.v1 | CardDealRefPayload |
MemberAdded | Info | None | pin.crm.deal-members-changed.v1 | CardDealRefPayload |
MemberRemoved | Info | None | pin.crm.deal-members-changed.v1 | CardDealRefPayload |
Value objects
Value objects не используются. IpAddress — типизированный примитив (хранение inet), не generic string.
Модели JSONB (versioned)
ContractVersion = 1; совместимость additive-only, breaking → новый ContractVersion. Поля с ? —
nullable; PII (ФИО/телефон/эскроу/бинарь) в payload запрещён — только id/типы/суммы/статусы.
Модель (ContractVersion = 1) | Поля (v1) |
|---|---|
CardUploadDocumentsPayload | { DealId: Guid, DocumentId: Guid, DocumentType: string, Title: string, RequestComment: string?, DueAt: DateTimeOffset? } |
CardBookingPaidPayload | { DealId: Guid, BookingId: Guid, Amount: decimal, CurrencyId: short, Currency: CurrencySnapshot, PaidAt: DateTimeOffset } |
CardStageChangedPayload | { DealId: Guid, FromStatus: string, ToStatus: string, ToStepName: string? } |
CardBookingExpiredPayload | { DealId: Guid, BookingId: Guid, ExpiredAt: DateTimeOffset } |
CardDealRefPayload | { DealId: Guid } (DealCreated/DealCompleted и прочие info-карточки со ссылкой на сделку) |
CurrencyId в CardBookingPaidPayload — ссылка на запись справочника валют; Currency —
snapshot-модель CurrencySnapshot (дом — handbook/domain/snapshots.md),
скопированная из события-источника: display валюты и исторический след на момент оплаты, переименования
справочника карточку не «догоняют».
Дочерние сущности агрегата
MessageEditEntity (таблица messenger.message_edits, append-only)
Снимок прошлого контента при правке/удалении; не отдаётся read-моделями, кроме admin-арбитража (INV-MSG-2).
| Поле | Тип | Обязательность | Ограничения | Описание |
|---|---|---|---|---|
Id | Guid | Да | PK | Идентификатор снимка. |
MessageId | Guid | Да | FK fk_message_edits_message_id → messenger.messages(id) cascade, index ix_message_edits_message_id | Сообщение. |
Revision | int | Да | >= 1; unique (MessageId,Revision) | Номер правки (1, 2, …). |
PreviousBody | string? | Нет | text | Прошлый текст (для Text). PII. |
PreviousCaption | string? | Нет | varchar(2000) | Прошлая подпись (для Attachment). PII. |
EditedAt | DateTimeOffset | Да | — | Момент правки/удаления. |
EditorUserId | Guid | Да | — | Кто изменил (= автор, INV-MSG-1). |
IpAddress | IpAddress? | Нет | inet | IP правки. Sensitive. |
UserAgent | string? | Нет | varchar(512) | UA правки. Sensitive. |
EventCardRecipientEntity (таблица messenger.event_card_recipients)
Per-participant видимость и текст карточки. Нет строк-получателей ⇒ карточка видна всем с DefaultText (INV-MSG-5).
| Поле | Тип | Обязательность | Ограничения | Описание |
|---|---|---|---|---|
Id | Guid | Да | PK | Идентификатор строки. |
MessageId | Guid | Да | FK fk_card_recipients_message_id → messenger.messages(id) cascade; unique (MessageId,UserId) | Карточка. |
UserId | Guid | Да | index ix_card_recipients_user_id | Получатель. |
IsVisible | bool | Да | default true | Видна ли карточка получателю. |
TextOverride | string? | Нет | varchar(1000) | Персональный текст вместо DefaultText. |
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|---|---|---|---|
ChatEntity | reference (родитель) | ChatId | не грузится с чатом; читается пагинированно по ChatId, порядок по Seq | cascade |
MessageEditEntity | child (в агрегате) | MessageId | append-only при правке/удалении; в read-моделях скрыт (кроме admin) | cascade |
EventCardRecipientEntity | child (в агрегате) | MessageId | include при list-messages — фильтр видимости per-participant | cascade |
| Файл (storage/CDN) | внешняя | FileId | пресайн download; бинарь вне БД (INV-MSG-7) | — (регламент storage) |
DealEntity (crm) | логическая (id в payload) | DealId / Payload.DealId | display/корреляция UI | — |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
| INV-MSG-1 | Править/удалять может только автор (SenderUserId = actor), пока now <= EditableUntil (≤60с) и Kind != EventCard / IsSystem = false; после — immutable | метод агрегата Edit/Delete + guard | MSG_EDIT_WINDOW_EXPIRED / MSG_EDIT_FORBIDDEN |
| INV-MSG-2 | Каждая правка/удаление пишет MessageEditEntity (append-only снимок прошлого); история не отдаётся read-моделями, кроме admin-арбитража | Edit/Delete в одной транзакции | — |
| INV-MSG-3 | Seq выдаётся атомарно из Chat.LastSeqCounter под конкуренцией; порядок ленты — по Seq | Chat.RegisterPosted + unique (ChatId,Seq) | MSG_SEQ_CONFLICT (retry) |
| INV-MSG-4 | EventCard/системные: SenderUserId = null, IsSystem = true, без IP/UA; пользователь не правит/не удаляет | фабрика карточки + guard | MSG_EDIT_FORBIDDEN |
| INV-MSG-5 | Видимость EventCard получателю R: нет recipient-строк ⇒ видно всем; иначе строка R c IsVisible = true; текст = TextOverride ?? DefaultText | list-messages (фильтр per-participant) | — |
| INV-MSG-6 | IP/UA обязательны для user-сообщений, класс Sensitive — не в события/логи/ответы (кроме admin) | отправка/правка сообщения + маппинг | MSG_ORIGIN_REQUIRED |
| INV-MSG-7 | Attachment хранит только FileId + метаданные; бинарь — в storage | проверка вложения | MSG_ATTACHMENT_INVALID |
| INV-MSG-8 | Идемпотентность карточек из crm: ux (ChatId,CardType,SourceEventId) — повторная доставка Kafka не дублирует | consumer события crm + unique | — (skip дубля) |
EventCard/системные (IsSystem = true) минуют Edited/Deleted — сразу иммутабельны (INV-MSG-4).
Конкурентность правки и нумерации. Окно правки/удаления (EditableUntil = SentAt + 60s) считается по
серверному времени — авторитет границы у сервера, не у клиента. Две одновременные правки одного сообщения
разрешает оптимистическая блокировка: проигравший перечитывает и повторяет, если ещё в окне; правка на границе
окна отклоняется, когда сервер уже за EditableUntil (MSG_EDIT_WINDOW_EXPIRED). Выдача Seq под конкуренцией
постов атомарна на строке чата (INV-MSG-3): при коллизии ux_messages_chat_seq проигравший получает следующий
Seq (MSG_SEQ_CONFLICT → retry), порядок ленты остаётся монотонным.
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|---|---|---|
MessagePosted | доменное | [*] → Sent | Chat.RegisterPosted (инкремент LastSeqCounter/unread) + realtime-dispatch push MessageReceived в hub |
MessageEdited / MessageDeleted | доменное | правка/удаление в окне | push MessageEdited / MessageDeleted в hub |
MessagePostedEvent | интеграционное, topic pin.messenger.message-posted.v1, ContractVersion = 1 | [*] → Sent | ChatId, MessageId, Seq, Kind, SenderUserId?, SenderRole?, SentAt, Preview (bounded: для Attachment — FileName/тип, для EventCard — DefaultText); без IP/UA; консюмер — notifications; контракт — ../events/message-posted.md |
Карточки messenger не публикует — их порождают консюмеры crm идемпотентно (INV-MSG-8). Все события
фиксируются через транзакционный outbox атомарно с мутацией (persist-then-push).
Индексы, хранение, безопасность
| Аспект | Значение |
|---|---|
| Индексы | PK; unique ux_messages_chat_seq (ChatId,Seq); (ChatId,SentAt) — история; (ChatId,Kind); ix_messages_sender (SenderUserId); ux ux_messages_card_source (ChatId,CardType,SourceEventId); message_edits: PK, (MessageId,Revision); event_card_recipients: PK, ux (MessageId,UserId), ix_card_recipients_user_id |
| Партиционирование / retention | none на целевой нагрузке (ADR-0056); пересмотр по росту истории — возможное партиционирование по ChatId/дате |
| Concurrency | оптимистическая блокировка на правке/удалении; выдача Seq — под unique-конкуренцией |
| Permissions / PII | messenger.chats.write (отправка); messenger.messages.edit (правка/удаление своего). PII: Body/Caption/PreviousBody/PreviousCaption. Sensitive: IpAddress/UserAgent — не в события/логи/ответы (кроме admin). История правок скрыта (кроме admin-арбитража) |
| Audit | версионирование + soft-delete; снимки правок MessageEditEntity — append-only факты арбитража |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (5): Entity / Aggregate: CallRecordingEntity — запись звонка (хранение в file storage), Entity / Aggregate: CallSessionEntity — сигналинг и лог звонка (media отложен), Entity / Aggregate: ChatEntity (+ParticipantEntity как owned-child), Entity / Aggregate: ChatEventEntity, Entity: ParticipantEntity (owned-child ChatEntity)
- API (9): DELETE /api/messenger/chats/{chatId}/messages/{messageId} — Удаление сообщения (мягкое, ≤ 1 мин), GET /api/messenger/admin/chats/{chatId}/transcript — Выгрузка переписки для арбитража, GET /api/messenger/chats/{chatId}/messages — История чата (keyset-пагинация), GET /api/messenger/recordings/{recordingId}/download — Ссылка на скачивание записи, 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 — Отметить чат прочитанным, SignalR Hub /hubs/messenger — MessengerHub (realtime-контракт)
- Use Cases (2): UC-MSG-001. Открыть личный чат по поиску пользователя по телефону, UC-MSG-003. Правка сообщения в окне ≤ 60 секунд
- События (2): События сервиса messenger: реестр топиков, Event Contract: pin.messenger.message-posted (MessagePostedEvent)
- Консюмеры (5): Consumer: Booking Expired Card (MessengerBookingExpiredConsumer), Consumer: Booking Paid Card (MessengerBookingPaidConsumer), Consumer: Deal Chat Provisioning (MessengerDealCreatedConsumer), Consumer: Deal Stage Card (MessengerDealStageChangedConsumer), Consumer: Document Request Card (MessengerDocumentRequestedConsumer)
- Фоновые задачи (1): Background Job: RealtimeDispatchJob
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
Связанные документы
- chat.md — родитель-агрегат,
LastSeqCounter,ParticipantRole, статус чата. - deal.md, deal-document.md — источники событий EventCard (контракты —
../../crm/events/); исходящий контракт — ../events/message-posted.md.