Entity: TicketMessageEntity (child of Ticket)
Дом полей дочерней сущности «Сообщение тикета» контекста support — переписка по тикету (матрица 4.1 «диалог
в рамках тикета») и материализация шаблонных ответов (4.3). Все проекции (тред-модель ответа, лента таймлайна
тикета, счётчики) ссылаются сюда и описывают только дельту.
Назначение
TicketMessageEntity — одно сообщение переписки внутри тикета: обращение заявителя, ответ оператора Службы
заботы или системная запись. Часть агрегата ticket.md: создаётся только методом Ticket.AddMessage,
который в той же транзакции обновляет счётчики тикета (MessagesCount, LastMessageAt, FirstResponseAt).
Хранится отдельной таблицей (отдельный дом по требованию брифа), но не является собственным aggregate root —
инварианты и запись оркеструет родитель.
Сообщение может быть публичным (виден заявителю) или внутренней заметкой оператора (Internal, 4.1). Недопустимый
контент снимается модерацией через soft-delete (3.7 в рамках тикета). Бинарные вложения лежат во внешнем
storage/CDN; в support — только типизированные метаданные и FileId.
Классификация
| Поле | Значение |
|---|
| Контекст | support |
| Kind | Entity (в составе агрегата Ticket; не отдельный root — отдельная таблица) |
| Source of truth | support (метаданные и текст); бинарь вложений — storage/CDN |
| Таблица | support.ticket_messages |
| Tenant isolation | TenantId — дублирует Ticket.TenantId (фильтр без join к support.tickets) |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|
Id | Guid | Да | PK | app | Идентификатор сообщения. |
TicketId | Guid | Да | FK fk_ticket_messages_ticket_id → support.tickets(id) on delete cascade; index ix_ticket_messages_ticket_id_created_at (TicketId,CreatedAt) | app | Родительский тикет (ticket.md). |
TenantId | Guid | Да | index | из тикета | Копия Ticket.TenantId — фильтрация без join. |
AuthorUserId | Guid | Да | — | app / actor | Автор сообщения (заявитель, оператор или системный actor); заполняет аудит-поле создателя (CreatedByUserId). |
AuthorKind | TicketMessageAuthorKind | Да | .HasConversion<string>(), varchar(16) | app | Роль автора (см. enum). |
Visibility | TicketMessageVisibility | Да | .HasConversion<string>(), varchar(16), default Public | app | Public — виден заявителю; Internal — внутренняя заметка оператора. |
Body | string | Да | varchar(8000), не пустое | app | Текст сообщения. Может содержать PII (не логируется целиком). |
TemplateId | Guid? | Нет | логическая ссылка на support.ticket_templates(id) (не FK) | app | Если сообщение сгенерировано из шаблонного ответа (4.3, ticket-template.md). |
Attachments | TicketMessageAttachments? | Нет | jsonb; типизированная модель ниже; ContractVersion = 1 | app | Метаданные вложений; null/пусто — вложений нет. |
CreatedAt | DateTimeOffset | Да | часть индекса ix_ticket_messages_ticket_id_created_at | app | Момент отправки (сортировка треда «старые → новые»). |
UpdatedAt | DateTimeOffset | Да | — | app | Момент последнего изменения. |
UpdatedByUserId | Guid? | Нет | — | app | Кто редактировал (обычно автор). |
DeletedAt | DateTimeOffset? | Нет | soft-delete filter | app | Модерация (3.7). |
DeletedByUserId | Guid? | Нет | — | app | Оператор/супервизор, снявший сообщение. |
Enum TicketMessageAuthorKind
| Значение | Описание |
|---|
Requester | Заявитель тикета (Ticket.RequesterUserId). Всегда пишет публично. |
Operator | Оператор Службы заботы (право support.tickets.manage). Может писать Public и Internal. |
System | Системная запись (авто-ответ, эскалация, смена статуса). Публичность задаёт источник. |
Enum TicketMessageVisibility
| Значение | Описание |
|---|
Public | Видно заявителю и операторам (тред переписки). |
Internal | Внутренняя заметка оператора; заявителю не отдаётся. |
Value objects
Value objects не используются (метаданные вложений — через типизированный jsonb).
Модели JSONB (versioned)
Совместимость — additive-only; breaking-изменение → новый ContractVersion. Поля с ? — nullable.
TicketMessageAttachments (jsonb, ContractVersion = 1)
| Поле | Тип | Обязательность | Ограничения | Since version |
|---|
Items | IReadOnlyList<TicketAttachment> | Да | пустой список = нет вложений | v1 |
TicketAttachment (элемент Items)
| Поле | Тип | Обязательность | Ограничения | Since version |
|---|
FileId | Guid | Да | ссылка на бинарь в storage/CDN | v1 |
FileName | string | Да | varchar(256) | v1 |
ContentType | string | Да | varchar(128) (MIME) | v1 |
SizeBytes | long | Да | > 0 and <= 26_214_400 (25 МБ) | v1 |
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|
TicketEntity | reference (родитель) | TicketId | GetById(TicketId) в Ticket.AddMessage; лента — Filter(paging, buildQuery: TicketId, orderBy: CreatedAt asc) | cascade (FK); тикеты обычно soft-delete — физический каскад крайний случай |
TicketTemplateEntity | reference (nullable) | TemplateId | не грузится (логическая ссылка на источник текста) | — |
| Файл (storage/CDN) | внешняя | Attachments.Items[].FileId | пресайн-ссылка на скачивание (tenantId + fileId) | — (регламент storage) |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|
| INV-MSG-1 | Сообщение с AuthorKind = Requester обязано иметь Visibility = Public (заявитель не пишет внутренние заметки) | при добавлении сообщения | SUPPORT_MESSAGE_VISIBILITY_INVALID |
| INV-MSG-2 | Visibility = Internal допустимо только при AuthorKind = Operator | при добавлении сообщения | SUPPORT_MESSAGE_INTERNAL_OPERATOR_ONLY |
| INV-MSG-3 | Автор — участник тикета (Ticket.RequesterUserId) либо оператор с правом support.tickets.manage | при добавлении сообщения (проверка актора и участия) | SUPPORT_MESSAGE_AUTHOR_FORBIDDEN |
| INV-MSG-4 | Нельзя добавить сообщение к терминальному (Closed) тикету без реопена (согласовано с INV-TKT-7) | при добавлении сообщения (guard по статусу тикета) | SUPPORT_TICKET_ALREADY_CLOSED |
| INV-MSG-5 | Первое Public-сообщение оператора выставляет Ticket.FirstResponseAt (INV-TKT-5); любое сообщение → MessagesCount++, LastMessageAt = now (в одном согласованном изменении) | при добавлении сообщения | — |
Конкурентность и согласованность записи
Само сообщение — append-only (собственной оптимистичной блокировки нет), но добавление идёт через родителя
Ticket.AddMessage, который в том же согласованном изменении правит счётчики тикета (MessagesCount,
LastMessageAt, при первом публичном ответе оператора — FirstResponseAt). Поэтому:
- Параллельные ответы в один тикет сериализуются оптимистичной блокировкой родителя-агрегата: побеждает
первый зафиксировавший, проигравший повторяет добавление на свежей версии тикета — оба сообщения в итоге
попадают в тред, счётчик не теряет инкременты.
FirstResponseAt ставится один раз (INV-MSG-5 / INV-TKT-5): при одновременных первых публичных ответах
двух операторов метку проставляет только выигравший переход; повторная установка не перезаписывает уже
проставленное значение (SLA-факт стабилен).
- Guard по статусу тикета (INV-MSG-4): если тикет к моменту фиксации перешёл в
Closed, добавление
отклоняется SUPPORT_TICKET_ALREADY_CLOSED — сообщение к терминальному тикету без реопена не пишется.
- Вложения: каждый элемент
Attachments.Items — SizeBytes ∈ (0, 25 МБ]; при нарушении размера/типа
вложение отклоняется валидацией (VALIDATION_FAILED) до сохранения сообщения; бинарь лежит вне БД
(storage/CDN), в support — только метаданные.
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|
TicketMessageAdded | доменное | добавление сообщения | TicketId, MessageId, AuthorKind, Visibility — обновляет счётчики/таймлайн тикета в одном согласованном изменении |
TicketMessagePostedKafkaEvent | интеграционное, topic pin.support.ticket-message-posted.v1, ContractVersion = 1 | публичный ответ оператора (reply-ticket, Visibility = Public) | TicketId, Number, TenantId, MessageId, RequesterUserId, AuthorUserId, AuthorKind, Visibility, HasAttachments, PostedAt; потребитель notifications уведомляет заявителя; контракт — ../events/ticket-message-posted.md |
Сама дочерняя сущность Kafka не публикует, но добавление публичного ответа оператором (reply-ticket)
поднимает доменное TicketMessageAdded, по которому агрегат-родитель публикует интеграционное
pin.support.ticket-message-posted.v1 в одном согласованном изменении — потребитель notifications уведомляет
заявителя о новом ответе (ticket-message-posted.md,
reply-ticket.md). Внутренние заметки (Internal) и сообщения самого
заявителя (Requester) этого события не порождают; возврат тикета в работу при ответе заявителя несёт
pin.support.ticket-status-changed.v1 (родитель ticket.md).
Индексы, хранение, безопасность
| Аспект | Значение |
|---|
| Индексы | PK; ix_ticket_messages_ticket_id_created_at (ticket_id,created_at); partial ix_ticket_messages_public (ticket_id,created_at) where visibility = 'Public' (быстрый public-тред) |
| Партиционирование / retention | none; бинарь вложений — вне БД (storage/CDN) |
| Concurrency | нет (append-only + модерация soft-delete) |
| Permissions / PII | Публичный заявитель видит только Public-сообщения своего тикета; support.tickets.view — все, включая Internal. PII: Body и FileName могут содержать персональные данные — в логи не пишется целиком (Id/усечённо). |
| Audit | append-only; стамп автора/редактора (кто и когда); модерация — soft-delete (иммутабельный факт снятия) |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (2): Entity / Aggregate: TicketEntity, Entity / Aggregate: TicketTemplateEntity
- API (8): GET /api/public/support/tickets — Мои обращения, GET /api/public/support/tickets/{id} — Моё обращение и переписка, GET /api/support/tickets/{id} — Карточка обращения (GetTicket), POST /api/public/support/tickets — Подать обращение, POST /api/public/support/tickets/{id}/messages — Ответ заявителя, POST /api/support/templates — Создать шаблон ответа, POST /api/support/tickets — Создать обращение от имени пользователя, POST /api/support/tickets/{id}/messages — Ответ оператора / внутренняя заметка
- Use Cases (1): UC-SUP-002. Решение обращения
- События (2): Event Contract: pin.support.ticket-created (TicketCreatedKafkaEvent), Event Contract: pin.support.ticket-message-posted (TicketMessagePostedKafkaEvent)
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
- index.md (1): Support Service
Связанные документы