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

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 заявителя).

Metadata

ПолеЗначение
Сервис/контекстsupport
API areapublic (заявитель)
Feature grouptickets
Статусdraft
Документdocs/06-services/support/api/public/add-ticket-message.md

Актор и доступ

ПроверкаЗначение
Actor typeUser (авторизованный заявитель)
Auth policy / PermissionsJWT (любой авторизованный пользователь); специальных permission нет; rate limit по UserId+TicketId
Scope (RBAC)не применяется (заявитель вне support-permissions; доступ — record-level)
Record-levelTicket.RequesterUserId == actor.UserId; чужой/несуществующий тикет неотличимы → SUPPORT_TICKET_NOT_FOUND (privacy)
Tenant isolationTenantId = PIN CompanyId — support single-tenant platform-tool (ADR-0052); фиксируется support scope-provider-ом, не берётся из tenant заявителя
Auditcreate (append сообщения; CreatedByUserId = AuthorUserId = actor); Body/FileName — PII, в логи целиком не пишутся

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/public/support/tickets/{id}/messages
Success status201 (сообщение добавлено)
Idempotency headerне применяется (append-only переписка; повтор создаёт новое сообщение, дедуп — на клиенте)
Correlationсквозной trace через заголовок traceparent

Входные данные / параметры запроса

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
ActorUserIdidentity claimsGuidДаиз JWT; = Ticket.RequesterUserId (шаг 2)
TicketIdroute {id}GuidДа!= Guid.Empty; существующий не-Closed тикет заявителя (шаг 2)
BodybodystringДане пустое после trim; длина 1..8000 (varchar(8000))PII: в логи целиком не пишется
AttachmentsbodyTicketMessageAttachments?Нетесли задан — 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Описание
MessageIdGuidДаTicketMessageEntity.Id (v7)Идентификатор созданного сообщения.
TicketIdGuidДаTicket.IdТикет треда.
NumberlongДаTicket.NumberЧеловекочитаемый номер SUP-NNN.
StatusTicketStatus (enum строкой)ДаTicket.Status после операцииТекущий статус (InWork при авто-реопене, иначе без изменений).
ReopenedboolДаcomputed (шаг 4)true — был авто-реопен Resolved → InWork.
MessagesCountintДаTicket.MessagesCountСчётчик сообщений после добавления.
CreatedAtDateTimeOffsetДаTicketMessageEntity.CreatedAtМомент отправки.

Алгоритм

  1. Контекст и доступ. Операцию выполняет авторизованный заявитель (actor User, UserId из JWT). support — единый платформенный tenant (TenantId = PIN CompanyId, фиксируется сервером, а не берётся из данных заявителя). TicketId — из маршрута {id}. Специальных permission нет; доступ — record-level (только своё обращение).
  2. Проверка входа и предусловий.
    • формат: Body не пустой после trim, длина 1..8000; Attachments (если заданы) — не более 20 элементов, каждый в границах TicketAttachment; TicketId не пустой. Нарушение → VALIDATION_FAILED.
    • существование и принадлежность: обращение должно существовать и принадлежать заявителю (RequesterUserId == UserId); отсутствующий, удалённый или чужой тикет неотличимы → SUPPORT_TICKET_NOT_FOUND (существование чужого обращения не раскрывается).
    • допустимость состояния: статус ∈ {New, InWork, Resolved}; ClosedSUPPORT_TICKET_ALREADY_CLOSED (INV-MSG-4 / INV-TKT-7).
  3. Добавление сообщения. В тред обращения добавляется новое публичное сообщение: Visibility = Public принудительно (заявитель не может писать Internal, INV-MSG-1); автор — заявитель (AuthorKind = Requester, автор уже подтверждён как участник обращения, INV-MSG-3). Счётчики обращения обновляются: MessagesCount++, LastMessageAt = now (INV-MSG-5). FirstResponseAt не трогается — его ставит только первый публичный ответ оператора (INV-TKT-5).
  4. Авто-реопен (опционально). Если обращение было Resolved → переход Resolved → InWork (INV-TKT-4, «возврат в работу»): оператор у обращения уже назначен, инвариант assignee соблюдён; ReopenedCount не инкрементируется (его ведёт только терминальный реопен Closed → InWork); ResolvedAt сохраняется как исторический факт. При New/InWork статус не меняется, реопена нет.
  5. Согласованность и события. Новое сообщение и обновление обращения (счётчики и, при реопене, статус) фиксируются в одном согласованном изменении. При реопене публикуется интеграционное 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 (перечитка).
  6. Результат. Возвращаются MessageId, TicketId, Number, текущий Status (InWork при реопене, иначе без изменений), признак Reopened, MessagesCount, CreatedAt → HTTP 201 Created.

Диаграмма

Побочные эффекты

ТипДетали
Integration eventsTicketStatusChangedEventpin.support.ticket-status-changed.v1 (только при авто-реопене Resolved → InWork), outbox; без реопена — none
External callsnone (бинарь вложений загружается в storage/CDN отдельным пресайн-потоком; здесь — только FileId/метаданные)
Cache / projections / searchElasticsearch-проекция support-tickets (search/tickets.md, ADR-0059): проектор обновляет MessagesCount/LastMessageAt при добавлении сообщения (тело сообщения в индекс тикета не входит); LastMessageAt меняет порядок списков оператора
Notificationsопосредованно: при реопене notifications — консюмер ticket-status-changed (оповестить оператора)

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400пустое/длинное Body, неверные вложения, пустой TicketId«Проверьте текст сообщения и вложения»no retry
SUPPORT_TICKET_NOT_FOUND404тикет не найден, soft-deleted или принадлежит другому заявителю«Обращение не найдено»no retry
SUPPORT_TICKET_ALREADY_CLOSED409Status = Closed — ответ запрещён без реопена (INV-MSG-4 / INV-TKT-7)«Обращение закрыто; создайте новое»no retry
CONCURRENCY_CONFLICT409гонка 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/ (будущий путь по конвенции).

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

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

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