GET /api/public/support/tickets/{id} — Моё обращение и переписка
Назначение
Возвращает заявителю карточку его обращения в Службу заботы ПИН и публичную переписку по нему (экран
«Мои обращения» → карточка тикета): тема, номер SUP-NNN, статус/приоритет/категория, счётчики и тред
сообщений «от старых к новым». В тред попадают только сообщения с Visibility = Public; внутренние
заметки операторов (Internal) заявителю не отдаются на уровне запроса (INV-MSG-2,
ticket-message.md). Вложения сообщений хранятся во внешнем storage/CDN — в
ответе отдаются короткоживущие presigned-ссылки на скачивание. Метод только читает: агрегат TicketEntity
не изменяется, события не публикуются.
| Поле | Значение |
|---|
| Сервис/контекст | support |
| API area | public |
| Feature group | tickets |
| Статус | draft |
| Документ | docs/06-services/support/api/public/get-my-ticket.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (заявитель — любой авторизованный пользователь, Bearer JWT) |
| Auth policy / Permissions | authenticated; специальный permission не требуется (публичная область, доступ к собственному тикету) — не путать с support.tickets.view оператора (../../README.md) |
| Scope (RBAC) | self (роль Службы заботы не требуется) |
| Record-level | только собственное обращение: предикат Ticket.RequesterUserId = actor.UserId; чужой тикет неотличим от несуществующего — единый SUPPORT_TICKET_NOT_FOUND (404, анти-enumeration) |
| Tenant isolation | не tenanted по tenant актора: заявитель вне PIN-tenant. support-сущности несут TenantId = PIN CompanyId (константа платформы, ADR-0052); фактический охват — record-level по RequesterUserId |
| Audit | read собственных данных; audit-запись не создаётся. PII заявителя (его же ФИО/контакты) в карточку не выносится; Body/FileName в логи целиком не пишутся |
HTTP-контракт
| Поле | Значение |
|---|
| Method | GET |
| Route | /api/public/support/tickets/{id} |
| Success status | 200 (в т.ч. пустой тред — Messages.Items = []) |
| Idempotency header | не применяется (GET — safe/idempotent по природе) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
RequesterUserId | identity claims (sub) | Guid | Да | валидный JWT — 401 до handler-а | record-level ключ |
TicketId | route {id} | Guid | Да | != Guid.Empty; существование + принадлежность — шаг 3 | record-level по Ticket.RequesterUserId |
Page | query | int | Нет (default 1) | >= 1 | — |
PageSize | query | int | Нет (default 50) | 1..100 (тред переписки — десятки строк) | защита от unbounded |
Сортировка треда — фиксированная серверная: по CreatedAt по возрастанию («старые → новые», tie-break по
Id). Клиентские сортировки/курсоры не принимаются.
Модель ответа TicketDetailModel
Карточка обращения + постраничный тред сообщений (Items, TotalCount, Page, PageSize). Дома полей —
ticket.md / ticket-message.md; ниже — только
состав проекции (дельта), без переописания сущностей.
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
Id | Guid | Да | TicketEntity.Id | — | Идентификатор обращения. |
Number | long | Да | .Number | — | Человекочитаемый номер (SUP-NNN). |
Subject | string | Да | .Subject | — | Тема обращения. |
Category | string (enum TicketCategory) | Да | .Category | — | Тематика. Enum строкой. |
Priority | string (enum TicketPriority) | Да | .Priority | — | Приоритет. Enum строкой. |
Status | string (enum TicketStatus) | Да | .Status | — | Статус жизненного цикла (New/InWork/Resolved/Closed). |
Channel | string (enum TicketChannel) | Да | .Channel | — | Канал поступления. Enum строкой. |
RelatedDealId | Guid? | Нет | .RelatedDealId | — | Связанная сделка (crm), если обращение по сделке. |
RelatedPropertyObjectId | Guid? | Нет | .RelatedPropertyObjectId | — | Связанный объект недвижимости (chessboard). |
MessagesCount | int | Да | .MessagesCount | — | Полный счётчик тикета (включает Internal; тред ниже — только Public). |
LastMessageAt | DateTimeOffset? | Нет | .LastMessageAt | — | Момент последнего сообщения (любой видимости). |
CreatedAt | DateTimeOffset | Да | .CreatedAt | — | Создание обращения. |
ResolvedAt | DateTimeOffset? | Нет | .ResolvedAt | — | Момент перехода в Resolved. |
ClosedAt | DateTimeOffset? | Нет | .ClosedAt | — | Момент перехода в Closed. |
Messages | постраничный список TicketMessageModel | Да | публичный тред (шаг 3) | — | Страница публичного треда (Items = [] — сообщений ещё нет). |
Внутренние поля наружу не проецируются: TenantId, AssigneeUserId, QueueId, RequesterUserId/
RequesterName/RequesterPhone/RequesterEmail (собственные данные заявителя дублировать не нужно),
SLA-поля (FirstResponseDueAt/ResolutionDueAt/SlaBreached), эскалация и audit-поля.
Модель TicketMessageModel (строка треда)
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
Id | Guid | Да | TicketMessageEntity.Id | — | Идентификатор сообщения. |
AuthorKind | string (enum TicketMessageAuthorKind) | Да | .AuthorKind | — | Requester / Operator / System. AuthorUserId наружу не проецируется. |
Body | string | Да | .Body | не логируется целиком (PII) | Текст сообщения. |
CreatedAt | DateTimeOffset | Да | .CreatedAt | — | Момент отправки (ключ сортировки asc). |
Attachments | TicketMessageAttachmentsModel? | Нет | owned jsonb .Attachments + presign (шаг 4) | — | Вложения с presigned-ссылками; null — вложений нет. |
Visibility в проекцию не выводится: тред по определению содержит только Public (фильтр на уровне запроса).
Модель TicketMessageAttachmentsModel (container)
| Поле | Тип | Обязательность | Источник | Описание |
|---|
Items | IReadOnlyList<TicketMessageAttachmentModel> | Да | TicketMessageAttachments.Items (ticket-message.md) | Список вложений (пустой = нет вложений). |
Модель TicketMessageAttachmentModel (элемент Items)
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
FileId | Guid | Да | TicketAttachment.FileId | — | Идентификатор бинаря в storage/CDN. |
FileName | string | Да | .FileName | не логируется целиком (PII) | Имя файла для сохранения. |
ContentType | string | Да | .ContentType | — | MIME-тип. |
SizeBytes | long | Да | .SizeBytes | — | Размер файла (> 0, ≤ 25 МБ). |
Url | string? | Нет | storage presign (TTL 5 мин) | не логируется (содержит подпись) | Presigned-ссылка; null — storage временно недоступен (best-effort деградация, метаданные остаются). |
ExpiresAt | DateTimeOffset? | Нет | storage | — | Истечение ссылки; null при деградации. |
Алгоритм
- Контекст и доступ. Операцию выполняет авторизованный заявитель (actor
User, RequesterUserId из
claim sub; без валидного JWT → 401). TicketId — из маршрута {id}, Page/PageSize — из query.
Специальный permission не требуется; доступ — record-level (только своё обращение).
- Проверка входа.
- синтаксис/диапазоны:
TicketId не пустой, Page >= 1, PageSize ∈ 1..100; нарушение →
VALIDATION_FAILED;
- существование + принадлежность (совмещены с чтением карточки, шаг 3): обращение должно существовать и
принадлежать заявителю (
RequesterUserId == actor); отсутствующий, чужой или удалённый тикет
неотличимы → SUPPORT_TICKET_NOT_FOUND (404, анти-enumeration);
- допустимость состояния не проверяется: карточка читается в любом
TicketStatus, включая терминальный
Closed (история обращения доступна всегда).
- Что читаем.
- (а) карточка обращения — по
Id с record-level-предикатом (RequesterUserId == actor, не удалённое);
отсутствие → 404;
- (б) публичный тред страницей: сообщения обращения с
Visibility == Public и не удалённые,
отсортированные по CreatedAt по возрастанию, страницей Page/PageSize; TotalCount — по тому же
предикату. Партиальный индекс ix_ticket_messages_public (ticket_id, created_at) покрывает и
предикат, и порядок. Internal-заметки исключаются на уровне запроса (defense-in-depth), а не
после выборки.
Порядок: сначала карточка (record-level guard решает, отдавать ли тред вообще).
- Presign вложений.
FileId всех вложений страницы собираются в один батч-запрос presigned-ссылок к
storage/CDN (TTL 5 минут; timeout 5 с, без retry); в ответ проставляются Url/ExpiresAt, а
FileName/ContentType/SizeBytes берутся из вложений сообщения (TicketMessageAttachments,
ContractVersion = 1). Сбой storage → Url/ExpiresAt = null для затронутых вложений (best-effort,
тред всё равно отдаётся 200).
- Результат. Возвращается
TicketDetailModel с постраничным тредом Messages → HTTP 200. Пустой
тред → 200 c Messages.Items = [], Messages.TotalCount = 0 (не 404). Отсутствие/чужой/удалённый
тикет → SUPPORT_TICKET_NOT_FOUND (404). Свежесть: карточка и тред — read-your-writes (основная БД);
presigned-ссылки — eventual/деградируемы.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Integration events | none (read-only query) |
| External calls | storage/CDN GetDownloadFileLinks(fileIds, ttl) — timeout 5 с, без retry, best-effort (сбой → Url = null) |
| Cache / projections / search | none (карточка и тред читаются query-time; source of truth — support.tickets / support.ticket_messages) |
| Notifications | none (уведомление заявителя о новом ответе доставляет notifications по pin.support.ticket-status-changed.v1 — ../../events/ticket-status-changed.md, не этот метод) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
VALIDATION_FAILED | 400 | TicketId = Guid.Empty, Page/PageSize вне границ | «Проверьте параметры запроса» | no retry |
UNAUTHORIZED | 401 | нет/просрочен JWT | «Требуется авторизация» | re-login |
SUPPORT_TICKET_NOT_FOUND | 404 | тикета нет / принадлежит другому заявителю (RequesterUserId != actor) / soft-deleted | «Обращение не найдено» | no retry |
| — (деградация, не ошибка) | 200 | storage недоступен/timeout при presign | вложения с Url = null (метаданные сохранены) | клиент может перезапросить |
Acceptance criteria
- AC-1 (happy path). Given своё обращение с публичным тредом; When
GET .../tickets/{id};
Then 200: карточка обращения (тема/номер/статус/приоритет/категория/счётчики) + страница публичных
сообщений, отсортированных по CreatedAt по возрастанию, с presigned-ссылками вложений (TTL 5 мин).
- AC-2 (пустой тред). Given обращение без публичных сообщений; When запрос; Then
200 с
Messages.Items = [], Messages.TotalCount = 0 (не 404).
- AC-3 (internal-заметки скрыты). Given обращение с внутренними заметками оператора
(
Visibility = Internal); When запрос; Then в тред попадают только Public-сообщения; при этом
MessagesCount в карточке — полный счётчик тикета (включает Internal), а тред — только публичный срез.
- AC-4 (чужое/несуществующее/удалённое — privacy). Given
TicketId чужого заявителя, несуществующий
или soft-deleted; When запрос; Then SUPPORT_TICKET_NOT_FOUND (404, анти-enumeration).
- AC-5 (история доступна в любом статусе). Given своё обращение в
Closed; When запрос; Then
200 — карточка и тред читаются в терминальном статусе (история всегда доступна).
- AC-6 (деградация storage). Given storage/CDN недоступен/timeout при presign; When запрос;
Then
200: тред отдаётся, у затронутых вложений Url = null/ExpiresAt = null, метаданные
(FileName/ContentType/SizeBytes) сохранены — не ошибка.
- AC-7 (валидация/доступ). Given
Page < 1/PageSize ∉ 1..100 → VALIDATION_FAILED (400);
Given отсутствует/просрочен JWT → UNAUTHORIZED (401).
Совместимость и наблюдаемость
- Новые optional поля карточки/сообщения и additive-поля вложений (
ContractVersion = 1, дом —
ticket-message.md) — без breaking change; клиент обязан игнорировать
неизвестные значения enum TicketStatus/TicketPriority/TicketCategory/TicketChannel/
TicketMessageAuthorKind (forward-compatible, enum строкой). Удаление/переименование/смена типа поля —
breaking (migration + rollout).
- Logs:
my-ticket-viewed (ticketId, requesterUserId, messagesReturned — без Body/FileName/PII);
metrics: support_public_operations_total{operation="get-my-ticket",result}, latency p95 < 1 c (SLO
списков, ../../README.md), support_storage_presign_total{result}; traces: HTTP-span →
запрос к хранилищу → запрос к storage; correlation через OTel и traceparent; dashboard pin-support.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы