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

GET /api/public/support/tickets — Мои обращения

Постраничный список обращений (тикетов) текущего заявителя — экран «Мои обращения» в ЛК пользователя (матрица 4.4/4.5: пользователь видит свои тикеты, статус и активность переписки). Заявитель видит только собственные тикеты; счётчик сообщений отражает видимый тред — внутренние заметки операторов (Visibility = Internal) в него не входят.

Назначение

Пользователь открывает «Мои обращения» и получает страницу своих тикетов с фильтрами по статусу и категории, отсортированную по активности переписки (свежие сверху). Owning-контекст — support; дом полей — ../../domain/ticket.md, тред — ../../domain/ticket-message.md. Feature group — tickets.

Metadata

ПолеЗначение
Сервис/контекстsupport
API areapublic
Feature grouptickets
Статусdraft
Документdocs/06-services/support/api/public/list-my-tickets.md

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

ПроверкаЗначение
Actor typeUser (любой авторизованный пользователь-заявитель; JWT)
Auth policy / Permissionsauthenticated user; отдельных support.* permissions нет (self-scope заявителя)
Scope (RBAC)self: только собственные обращения
Record-levelRequesterUserId == actor.UserId — обязательный предикат выборки (задаётся из claim sub, не из query)
Tenant isolationзаявитель — вне PIN-tenant; owned-тикеты несут фиксированный TenantId = PIN CompanyId (ADR-0052). Actor-tenant-интерсептор не применяется (bypass для public self-scope); изоляция обеспечивается record-level предикатом RequesterUserId
Auditnone (чтение собственных данных); PII заявителя в проекцию не входит

HTTP-контракт

ПолеЗначение
MethodGET
Route/api/public/support/tickets
Success status200
Idempotency headerне применяется (безопасный read)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
RequesterUserIdidentity claims (sub)GuidДаRequired; не принимается из query; отсутствие → 401record-level фильтр
StatusqueryTicketStatus?Нетenum-строка New/InWork/Resolved/Closed; unknown → VALIDATION_FAILED
CategoryqueryTicketCategory?Нетenum-строка Account/Deal/Booking/Payment/Content/Technical/Complaint/Other
SortqueryMyTicketsSort?Нет (default LastMessageAtDesc)enum-строка (см. ниже)
PagequeryintНет (default 1)>= 1
PageSizequeryintНет (default 20)1..100

Body отсутствует.

Enum MyTicketsSort

ЗначениеПравило сортировки
LastMessageAtDescLastMessageAt DESC NULLS LAST, tie-break CreatedAt DESC (default — свежая переписка сверху).
CreatedAtDescCreatedAt DESC (сначала недавно созданные обращения).

Модель ответа: постраничный список TicketListItemModel

Постраничная выдача: Items (список TicketListItemModel), Page, PageSize, TotalCount. Дом полей тикета — ../../domain/ticket.md.

TicketListItemModel

ПолеТипОбязательностьИсточникMaskingОписание
NumberlongДаTicket.NumberЧеловекочитаемый номер (SUP-NNN); ключ перехода в карточку get-my-ticket.
SubjectstringДаTicket.SubjectТема обращения.
StatusTicketStatus (enum-строка)ДаTicket.StatusСтатус жизненного цикла.
PriorityTicketPriority (enum-строка)ДаTicket.PriorityПриоритет (Low/Normal/High/Urgent).
CategoryTicketCategory (enum-строка)ДаTicket.CategoryТематика обращения.
LastMessageAtDateTimeOffset?НетTicket.LastMessageAtМомент последнего сообщения; null — сообщений ещё нет.
MessagesCountintДаcomputed (подзапрос, шаг 3)Сообщений в видимом треде: Public и не удалённые; Internal-заметки оператора исключены (≠ Ticket.MessagesCount).

Id наружу не отдаётся (внутренний ключ агрегата); детальная карточка запрашивается по Number (get-my-ticket). PII заявителя (RequesterName/RequesterPhone/RequesterEmail) в проекцию не включается — владелец совпадает с актором.

Алгоритм

  1. Контекст и доступ. Операцию выполняет авторизованный заявитель (actor User, RequesterUserId — только из claim sub, не из query). Специальный permission не требуется; изоляция — record-level (только свои обращения; заявитель вне PIN-tenant).
  2. Проверка входа. Page >= 1, PageSize ∈ 1..100; Status/Category/Sort — валидные enum-строки (unknown → VALIDATION_FAILED). Ссылочных id во входе нет.
  3. Что читаем. Страница обращений заявителя: обязательный предикат RequesterUserId == actor и не удалённые, опциональные фильтры по Status и Category, сортировка по Sort (LastMessageAtDesc по умолчанию), страница Page/PageSize, TotalCount — по тому же предикату. MessagesCount считается по видимому треду (Visibility == Public, не удалённые; Internal-заметки исключены, INV-MSG-1/2) — отдельным подсчётом, переписка не материализуется. Ведущий индекс — ix_tickets_requester_user_id; Status/Category — остаточные предикаты; сортировка LastMessageAtDesc опирается на ix_tickets_last_message_at; счётчик public-треда — на partial-индекс ix_ticket_messages_public.
  4. Маппинг. Поле-в-поле по TicketListItemModel: Number/Subject/Status/Priority/Category/ LastMessageAt — из обращения; MessagesCount — public-count из шага 3; enum-поля сериализуются строкой.
  5. Результат. Постраничный список TicketListItemModel → HTTP 200. PII заявителя в проекцию не входит. Свежесть: read-your-writes из основной БД (реплик нет, ADR-0056).

Диаграмма

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

ТипДетали
Integration eventsnone
External callsnone (display-данные заявителя не нужны — владелец = актор)
Cache / projections / searchnone (btree/partial PostgreSQL, ADR-0056)
Notificationsnone

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400Page/PageSize вне диапазона или неизвестное значение enum Status/Category/Sort«Некорректные параметры запроса»no retry
UNAUTHORIZED401нет/просрочен JWT заявителяretry после refresh

Acceptance criteria

  • AC-1 (happy path). Given авторизованный заявитель со своими обращениями; When GET /api/public/support/tickets; Then 200: страница только его обращений, отсортированная LastMessageAtDesc (свежая переписка сверху), MessagesCount считается по видимому треду (Public, не удалённые; Internal-заметки исключены).
  • AC-2 (фильтры). Given заданы Status/Category; When запрос; Then выдача сужена этими предикатами поверх обязательного RequesterUserId == actor.
  • AC-3 (сортировка по созданию). Given Sort = CreatedAtDesc; When запрос; Then сортировка по CreatedAt DESC.
  • AC-4 (record-level). Given заявитель пытается передать чужой RequesterUserId в query; When запрос; Then параметр игнорируется — фильтр берётся только из claim sub; чужие обращения не видны.
  • AC-5 (валидация). Given Page < 1/PageSize ∉ 1..100 или неизвестное значение enum Status/Category/Sort; When запрос; Then VALIDATION_FAILED (400).
  • AC-6 (не аутентифицирован). Given нет/просрочен JWT; When запрос; Then UNAUTHORIZED (401).
  • AC-7 (пустой список). Given у заявителя нет обращений (под фильтром); When запрос; Then 200 с Items = [], TotalCount = 0.

Совместимость и наблюдаемость

  • Новые optional поля ответа — additive; удаление/переименование/смена типа — breaking (migration + rollout). Unknown-значение enum Status/Category/Sort в query → VALIDATION_FAILED.
  • Logs: RequesterUserId, применённые Status/Category/Sort, Page/PageSize, TotalCount (Subject/PII не логируются); metrics: support_api_request_duration_seconds{route}; traces: OTel span list-my-tickets; dashboard pin-support.

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

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

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

  • Сущности: ../../domain/ticket.md (Number/Status/Priority/Category/ LastMessageAt, индексы ix_tickets_requester_user_id/ix_tickets_last_message_at), ../../domain/ticket-message.md (Visibility Public/Internal, ix_ticket_messages_public).
  • Границы public-области и модель доступа: ../../README.md; ADR-0052 (tenancy), ADR-0056 (simplicity-first).
  • Соседние операции: get-my-ticket, create-ticket, add-ticket-message (public-область, будущие пути по конвенции).