POST /api/public/support/tickets/{id}/messages — Ответ заявителя
Публичная точка входа «заявитель отвечает по своему обращению» Службы заботы ПИН (матрица 4.1 «диалог в
рамках тикета», 4.5). Заявитель дописывает публичное сообщение в тред своего тикета
(ticket-message.md); переписка — дочерняя часть обращения
TicketEntity, и при добавлении сообщения в том же согласованном изменении
обновляются счётчики обращения. Ответ оператора (с выбором Public/Internal и шаблонами) — отдельная
support-операция reply-ticket; здесь автор всегда заявитель и видимость принудительно публичная (INV-MSG-1).
Назначение
Принять от авторизованного заявителя текст ответа (+ опциональные вложения) по его тикету, добавить
публичное сообщение в тред, обновить счётчики (MessagesCount, LastMessageAt) и — если тикет уже был
Resolved — вернуть его в работу (авто-реопен Resolved → InWork) с интеграционным уведомлением оператора.
Owning-контекст — support, feature group tickets, публичный гейт (JWT заявителя).
| Поле | Значение |
|---|
| Сервис/контекст | support |
| API area | public (заявитель) |
| Feature group | tickets |
| Статус | draft |
| Документ | docs/06-services/support/api/public/add-ticket-message.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (авторизованный заявитель) |
| Auth policy / Permissions | JWT (любой авторизованный пользователь); специальных permission нет; rate limit по UserId+TicketId |
| Scope (RBAC) | не применяется (заявитель вне support-permissions; доступ — record-level) |
| Record-level | Ticket.RequesterUserId == actor.UserId; чужой/несуществующий тикет неотличимы → SUPPORT_TICKET_NOT_FOUND (privacy) |
| Tenant isolation | TenantId = PIN CompanyId — support single-tenant platform-tool (ADR-0052); фиксируется support scope-provider-ом, не берётся из tenant заявителя |
| Audit | create (append сообщения; CreatedByUserId = AuthorUserId = actor); Body/FileName — PII, в логи целиком не пишутся |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/public/support/tickets/{id}/messages |
| Success status | 201 (сообщение добавлено) |
| Idempotency header | не применяется (append-only переписка; повтор создаёт новое сообщение, дедуп — на клиенте) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
ActorUserId | identity claims | Guid | Да | из JWT; = Ticket.RequesterUserId (шаг 2) | — |
TicketId | route {id} | Guid | Да | != Guid.Empty; существующий не-Closed тикет заявителя (шаг 2) | — |
Body | body | string | Да | не пустое после trim; длина 1..8000 (varchar(8000)) | PII: в логи целиком не пишется |
Attachments | body | TicketMessageAttachments? | Нет | если задан — Items.Count <= 20; поэлементно (ниже) | FileName — PII (маска в логах) |
Вложения — типизированная jsonb-модель TicketMessageAttachments (дом — ticket-message.md,
ContractVersion = 1): Items : IReadOnlyList<TicketAttachment>, элемент TicketAttachment =
{ FileId : Guid (!= Guid.Empty), FileName : string (1..256), ContentType : string (1..128, MIME),
SizeBytes : long (> 0 and <= 26_214_400) }. Голый object/json не используется.
Модель ответа AddTicketMessageResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
MessageId | Guid | Да | TicketMessageEntity.Id (v7) | — | Идентификатор созданного сообщения. |
TicketId | Guid | Да | Ticket.Id | — | Тикет треда. |
Number | long | Да | Ticket.Number | — | Человекочитаемый номер SUP-NNN. |
Status | TicketStatus (enum строкой) | Да | Ticket.Status после операции | — | Текущий статус (InWork при авто-реопене, иначе без изменений). |
Reopened | bool | Да | computed (шаг 4) | — | true — был авто-реопен Resolved → InWork. |
MessagesCount | int | Да | Ticket.MessagesCount | — | Счётчик сообщений после добавления. |
CreatedAt | DateTimeOffset | Да | TicketMessageEntity.CreatedAt | — | Момент отправки. |
Алгоритм
- Контекст и доступ. Операцию выполняет авторизованный заявитель (actor
User, UserId из JWT).
support — единый платформенный tenant (TenantId = PIN CompanyId, фиксируется сервером, а не берётся из
данных заявителя). TicketId — из маршрута {id}. Специальных permission нет; доступ — record-level
(только своё обращение).
- Проверка входа и предусловий.
- формат:
Body не пустой после trim, длина 1..8000; Attachments (если заданы) — не более 20
элементов, каждый в границах TicketAttachment; TicketId не пустой. Нарушение → VALIDATION_FAILED.
- существование и принадлежность: обращение должно существовать и принадлежать заявителю
(
RequesterUserId == UserId); отсутствующий, удалённый или чужой тикет неотличимы →
SUPPORT_TICKET_NOT_FOUND (существование чужого обращения не раскрывается).
- допустимость состояния: статус ∈ {
New, InWork, Resolved}; Closed →
SUPPORT_TICKET_ALREADY_CLOSED (INV-MSG-4 / INV-TKT-7).
- Добавление сообщения. В тред обращения добавляется новое публичное сообщение:
Visibility = Public
принудительно (заявитель не может писать Internal, INV-MSG-1); автор — заявитель
(AuthorKind = Requester, автор уже подтверждён как участник обращения, INV-MSG-3). Счётчики обращения
обновляются: MessagesCount++, LastMessageAt = now (INV-MSG-5). FirstResponseAt не трогается —
его ставит только первый публичный ответ оператора (INV-TKT-5).
- Авто-реопен (опционально). Если обращение было
Resolved → переход Resolved → InWork (INV-TKT-4,
«возврат в работу»): оператор у обращения уже назначен, инвариант assignee соблюдён; ReopenedCount
не инкрементируется (его ведёт только терминальный реопен Closed → InWork); ResolvedAt
сохраняется как исторический факт. При New/InWork статус не меняется, реопена нет.
- Согласованность и события. Новое сообщение и обновление обращения (счётчики и, при реопене, статус)
фиксируются в одном согласованном изменении. При реопене публикуется интеграционное
TicketStatusChangedEvent (топик pin.support.ticket-status-changed.v1, ContractVersion = 1,
AggregateId = TicketId, payload { TicketId, Number, TenantId, RequesterUserId, OldStatus = Resolved, NewStatus = InWork, AssigneeUserId, ChangeReason = "requester-reply", ChangedAt }) через транзакционный
outbox — консюмер notifications оповещает оператора о возврате обращения. Без реопена (New/InWork)
интеграционных событий нет: оператор видит ответ по LastMessageAt в списке. Конкурентная гонка с
SLA-worker/оператором разводится оптимистичной блокировкой; конфликт → CONCURRENCY_CONFLICT (перечитка).
- Результат. Возвращаются
MessageId, TicketId, Number, текущий Status (InWork при реопене,
иначе без изменений), признак Reopened, MessagesCount, CreatedAt → HTTP 201 Created.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Integration events | TicketStatusChangedEvent → pin.support.ticket-status-changed.v1 (только при авто-реопене Resolved → InWork), outbox; без реопена — none |
| External calls | none (бинарь вложений загружается в storage/CDN отдельным пресайн-потоком; здесь — только FileId/метаданные) |
| Cache / projections / search | Elasticsearch-проекция support-tickets (search/tickets.md, ADR-0059): проектор обновляет MessagesCount/LastMessageAt при добавлении сообщения (тело сообщения в индекс тикета не входит); LastMessageAt меняет порядок списков оператора |
| Notifications | опосредованно: при реопене notifications — консюмер ticket-status-changed (оповестить оператора) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
VALIDATION_FAILED | 400 | пустое/длинное Body, неверные вложения, пустой TicketId | «Проверьте текст сообщения и вложения» | no retry |
SUPPORT_TICKET_NOT_FOUND | 404 | тикет не найден, soft-deleted или принадлежит другому заявителю | «Обращение не найдено» | no retry |
SUPPORT_TICKET_ALREADY_CLOSED | 409 | Status = Closed — ответ запрещён без реопена (INV-MSG-4 / INV-TKT-7) | «Обращение закрыто; создайте новое» | no retry |
CONCURRENCY_CONFLICT | 409 | гонка RowVersion (xmin) с SLA-worker/оператором | «Повторите попытку» | retry (перечитка тикета) |
Код закрытого тикета — канонический SUPPORT_TICKET_ALREADY_CLOSED из дома инвариантов
(ticket-message.md INV-MSG-4, ticket.md
INV-TKT-7); отдельного SUPPORT_TICKET_CLOSED не вводится (анти-дублирование кодов).
Acceptance criteria
- AC-1 (ответ без реопена). Given своё обращение в статусе
New/InWork, непустой Body 1..8000;
When POST /api/public/support/tickets/{id}/messages; Then 201: добавлено публичное сообщение
(Visibility = Public, AuthorKind = Requester), MessagesCount++, LastMessageAt = now, Reopened = false, статус без изменений; FirstResponseAt не трогается (его ставит первый ответ оператора,
INV-TKT-5); интеграционных событий нет — оператор видит ответ по LastMessageAt в списке.
- AC-2 (авто-реопен из Resolved). Given своё обращение
Resolved с назначенным оператором; When
добавление сообщения; Then 201: сообщение добавлено, переход Resolved → InWork (INV-TKT-4),
Reopened = true, ReopenedCount не инкрементируется, ResolvedAt сохраняется как исторический факт;
публикуется TicketStatusChangedEvent (OldStatus = Resolved, NewStatus = InWork,
ChangeReason = "requester-reply") → консюмер notifications оповещает оператора о возврате обращения.
- AC-3 (видимость принудительно публичная). Given заявитель; When добавление сообщения; Then
сообщение всегда
Visibility = Public (заявитель не может создавать Internal, INV-MSG-1).
- AC-4 (закрытое обращение). Given своё обращение
Closed; When добавление сообщения; Then
SUPPORT_TICKET_ALREADY_CLOSED (409, INV-MSG-4/INV-TKT-7) — «создайте новое»; сообщение не добавлено.
- AC-5 (чужое/несуществующее — privacy). Given
TicketId чужого заявителя, несуществующий или
soft-deleted; When добавление; Then SUPPORT_TICKET_NOT_FOUND (404) — существование чужого
обращения не раскрывается.
- AC-6 (валидация). Given пустой/
>8000 Body либо Attachments.Items > 20 / невалидный элемент;
When добавление; Then VALIDATION_FAILED (400).
- AC-7 (конкуренция). Given параллельная правка обращения SLA-worker-ом/оператором (сменился
RowVersion); When добавление; Then CONCURRENCY_CONFLICT (409); заявитель перечитывает тикет и
повторяет.
Совместимость и наблюдаемость
- Новые optional поля запроса/ответа — additive; удаление/переименование/смена типа
Body/Attachments —
breaking (migration + rollout). TicketMessageAttachments — additive-only, breaking → новый
ContractVersion. Enum TicketStatus в ответе — строкой; неизвестное значение клиент игнорирует.
- Logs:
ticket-message added (TicketId, MessageId, Reopened; Body/FileName — маской/усечённо);
metrics: support_ticket_messages_total{author="requester",reopened}, latency p95 (< 500 мс, SLO README);
traces — OTel; dashboard pin-support; runbook — docs/06-services/support/runbooks/ (будущий путь по
конвенции).
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы