POST /api/public/support/tickets — Подать обращение
Публичная точка входа «форма обратной связи» (матрица 4.4): любой авторизованный пользователь заводит
обращение в Службу заботы ПИН. Операция создаёт обращение TicketEntity вместе с
первым публичным сообщением TicketMessageEntity, маршрутизирует тикет в
очередь (TicketQueueEntity) и публикует интеграционное событие
pin.support.ticket-created.v1.
Назначение
Принять форму обращения (тема, категория, текст, опционально связь со сделкой/объектом недвижимости и вложения) от
авторизованного заявителя, детерминированно направить в очередь поддержки по категории и вернуть карточку
созданного тикета для оптимистичного отображения. Owning-контекст — support, feature group ticket.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | support |
| API area | public (JWT авторизованного пользователя) |
| Feature group | ticket |
| Статус | draft |
| Документ | docs/06-services/support/api/public/create-ticket.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (любой авторизованный заявитель) |
| Auth policy / Permissions | none (own action — заявитель создаёт своё обращение); требуется лишь аутентификация |
| Scope (RBAC) | не применяется (own action; support — platform single-tenant, ADR-0052) |
| Record-level | владелец = RequesterUserId (актор); доступ к своим тикетам — get-my-ticket |
| Tenant isolation | TenantId = PIN CompanyId — фиксируется сервером (платформенный tenant), не берётся из запроса; контекст заявителя — колонки RequesterCompanyId/RequesterRole |
| Audit | create (CreatedByUserId = RequesterUserId); PII заявителя и Body — маска/усечение в логах |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/public/support/tickets |
| Success status | 201 (создано) |
| Idempotency header | не применяется (каждое обращение — отдельный тикет) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
RequesterUserId | identity claims (JWT sub) | Guid | Да | не Guid.Empty; логическая ссылка identity.users(id) | владелец записи |
RequesterCompanyId | identity claims | Guid? | Нет | снапшот компании заявителя (контекст/фильтр) | — |
RequesterRole | identity claims | string? | Нет | <=64; снапшот роли на момент обращения | — |
Subject | body | string | Да | 1..256, не пустое после trim; индексируется в Elasticsearch (support-tickets) | — |
Category | body | TicketCategory (enum строкой) | Нет | значение enum ticket.md; неизвестное/пустое → Other (default) | — |
Body | body | string | Да | 1..8000, не пустое (текст первого сообщения) | PII: в логи не пишется целиком |
RelatedDealId | body | Guid? | Нет | логическая ссылка crm; синхронно не проверяется (граница контекста) | — |
RelatedPropertyObjectId | body | Guid? | Нет | логическая ссылка chessboard; синхронно не проверяется | — |
Attachments | body | IReadOnlyList<TicketAttachmentInput>? | Нет | null/пусто — вложений нет; элемент — модель ниже | метаданные; бинарь — storage/CDN |
Модель TicketAttachmentInput (элемент Attachments)
Проецируется в jsonb TicketAttachment — дом полей ticket-message.md.
| Поле | Тип | Обязательность | Валидация / Ограничения |
|---|---|---|---|
FileId | Guid | Да | ссылка на бинарь в storage/CDN (пресайн выполнен клиентом заранее) |
FileName | string | Да | 1..256 |
ContentType | string | Да | 1..128 (MIME) |
SizeBytes | long | Да | > 0 and <= 26_214_400 (25 МБ) |
Модель ответа TicketCardModel
Проекция агрегата TicketEntity (дом полей) — здесь только возвращаемая дельта.
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
TicketId | Guid | Да | TicketEntity.Id | — | Идентификатор тикета. |
Number | long | Да | TicketEntity.Number (PG sequence) | — | Человекочитаемый номер (SUP-NNN). |
Subject | string | Да | TicketEntity.Subject | — | Тема обращения. |
Category | TicketCategory | Да | TicketEntity.Category | — | Тематика (enum строкой). |
Priority | TicketPriority | Да | TicketEntity.Priority | — | = Queue.DefaultPriority. |
Status | TicketStatus | Да | TicketEntity.Status | — | New при создании. |
Queue | QueueRefModel | Да | TicketEntity.QueueId + TicketQueueEntity.Name | — | Очередь маршрутизации (вложенная ref-модель { QueueId, QueueName }). |
Channel | TicketChannel | Да | TicketEntity.Channel | — | WebForm. |
RequesterName | FullName? | Нет | реплика identity | И*** | ФИО заявителя; null — реплика отложена консюмером (см. побочные). |
CreatedAt | DateTimeOffset | Да | TicketEntity.CreatedAt | — | Момент создания. |
FirstResponseDueAt | DateTimeOffset | Да | TicketEntity.FirstResponseDueAt | — | Дедлайн первого ответа (SLA по Priority). |
MessagesCount | int | Да | TicketEntity.MessagesCount | — | 1 (первое сообщение). |
FirstMessage | TicketMessageModel | Да | созданное сообщение | Body не логируется | Эхо первого публичного сообщения. |
Вложенная ref-модель QueueRefModel: QueueId (Guid), QueueName (string, имя резолвнутой очереди
ticket-queue.md). Плоская пара свёрнута в объект queue (строгая типизация).
TicketMessageModel — проекция TicketMessageEntity (дом полей): MessageId
(Guid), AuthorKind (TicketMessageAuthorKind = Requester), Visibility (TicketMessageVisibility =
Public), Body (string), Attachments (TicketMessageAttachments?), CreatedAt (DateTimeOffset).
Полная переписка возвращается отдельно (get-my-ticket) постранично.
Алгоритм
- Контекст и доступ. Операцию выполняет любой авторизованный заявитель (actor
User);RequesterUserIdберётся из JWT,RequesterCompanyId/RequesterRole— из снапшота claims. support — единый платформенный tenant (TenantId = PIN CompanyId, серверная константа). Permission не требуется (own action); неаутентифицированный запрос →UNAUTHENTICATED(401). - Проверка входа.
Subject1..256,Body1..8000(оба не пустые после trim);Category— допустимое значение enum, иначеOther(default); каждый элементAttachments—FileName 1..256,ContentType 1..128,SizeBytes 1..26_214_400. Нарушение →VALIDATION_FAILED. СсылкиRelatedDealId/RelatedPropertyObjectId— логические (границы контекста), синхронно не проверяются. - Маршрутизация в очередь. Очередь подбирается по категории: активная очередь с
RoutingCategory = Category(INV-Q-5); при отсутствии совпадения — активная default-очередь; если активной default-очереди нет →SUPPORT_QUEUE_NOT_CONFIGURED(нарушение INV-Q-2/SEED). Реплика заявителя подтягивается запросом вidentityпоRequesterUserId(timeout 2 с) —FullName/Phone/Email+IdentityRequesterVersion; timeout/недоступность — не ошибка: поля остаютсяnull, реплику догонит консюмерpin.identity.users.v1(поRequesterUserId, с version-guard). - Создание обращения (INV-TKT-1). Новое обращение:
Status = New,AssigneeUserId = null,Channel = WebForm(иммутабельно),Priority = Queue.DefaultPriority,QueueId— резолвнутой очереди,Number— из последовательности,FirstResponseDueAt/ResolutionDueAt— по SLA-политике отPriority;RequesterUserId/RequesterCompanyId/RequesterRole/RelatedDealId/RelatedPropertyObjectId— из входа;RequesterName/Phone/Email/IdentityRequesterVersion— из lookup (илиnull). Тут же добавляется первое публичное сообщение (INV-MSG-1:Requester→Public; INV-MSG-5:MessagesCount = 1,LastMessageAt = now). - Согласованность и события. Обращение и первое сообщение фиксируются в одном согласованном изменении;
очередь при этом не меняется. Публикуется интеграционное
TicketCreatedEvent(топикpin.support.ticket-created.v1,ContractVersion = 1,AggregateType = Ticket,AggregateId = TicketId; payload-snapshot{TicketId, Number, TenantId, QueueId, Category, Priority, Status, RequesterUserId, RequesterCompanyId?, RelatedDealId?, Channel, CreatedAt}) через транзакционный outbox (контракт — ../../events/ticket-created.md); консюмер —notifications. - Результат. Возвращается карточка
TicketCardModel(эхоFirstMessage,Queue = { QueueId, QueueName }резолвнутой очереди,RequesterNameпод маской) → HTTP201.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | TicketCreatedEvent → pin.support.ticket-created.v1 (ContractVersion = 1), транзакционный outbox; консюмер — notifications (оповестить дежурных очереди) |
| External calls | identity internal lookup реплики заявителя (timeout 2с, без retry; graceful degradation — отложенная реплика консюмером pin.identity.users.v1) |
| Cache / projections / search | Elasticsearch-проекция support-tickets (search/tickets.md, ADR-0059) — upsert документа TicketSearchDocument проектором при создании обращения (near-real-time) |
| Notifications | опосредованно: notifications — консюмер ticket-created (дежурные операторы очереди) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
UNAUTHENTICATED | 401 | нет/невалидный JWT (actor не User) | «Требуется авторизация» | no retry |
VALIDATION_FAILED | 400 | Subject/Body/Attachments вне ограничений | «Проверьте корректность полей формы» | no retry |
SUPPORT_QUEUE_NOT_CONFIGURED | 500 | нет активной default-очереди (нарушение INV-Q-2/SEED) | «Сервис временно недоступен» | no retry (нужна конфигурация очередей) |
Identity-lookup timeout (2с) — не failure: тикет создаётся, реплика ФИО/контактов отложена (eventual).
Acceptance criteria
- AC-1 (happy path). Given авторизованный заявитель,
Subject 1..256иBody 1..8000заполнены, есть активная очередь сRoutingCategory = Category; WhenPOST /api/public/support/tickets; Then201: создано обращениеStatus = New,Channel = WebForm,Priority = Queue.DefaultPriority,QueueId— резолвнутой очереди,Numberиз последовательности,FirstResponseDueAt/ResolutionDueAtпо SLA отPriority; в тред добавлено первое публичное сообщение (AuthorKind = Requester,Visibility = Public),MessagesCount = 1,LastMessageAt = CreatedAt. Постусловие:TicketCreatedEventушёл в outbox (топикpin.support.ticket-created.v1), консюмерnotificationsоповестит дежурных очереди. - AC-2 (маршрутизация по умолчанию). Given активной очереди с
RoutingCategory = Categoryнет, но есть активная default-очередь; When создание; Then обращение маршрутизируется в default-очередь (INV-Q-5),Priority = defaultQueue.DefaultPriority. - AC-3 (неизвестная категория). Given
Categoryпуст или вне enum; When создание; Then тикет создаётся сCategory = Other(default),201— не ошибка. - AC-4 (реплика заявителя отложена). Given
identityне ответил за 2 с (timeout/недоступность); When создание; Then201: тикет создан,RequesterName/Phone/Email = null; реплику ФИО/контактов догоняет консюмерpin.identity.users.v1поRequesterUserId(version-guard) — окно eventual-consistency; пользователь видит карточку без своего ФИО до синхронизации. - AC-5 (нет активной default-очереди). Given ни одной активной очереди под категорию и нет активной
default (нарушение INV-Q-2/SEED); When создание; Then
SUPPORT_QUEUE_NOT_CONFIGURED(500), тикет не создан, событие не публикуется. - AC-6 (валидация формы). Given пустой/слишком длинный
Subject/Bodyили вложение вне границ (FileName > 256,ContentType > 128,SizeBytesвне1..26_214_400); When создание; ThenVALIDATION_FAILED(400), ничего не создано. - AC-7 (не аутентифицирован). Given запрос без валидного JWT; When создание; Then
UNAUTHENTICATED(401).
Совместимость и наблюдаемость
RelatedDealId/RelatedPropertyObjectId/Attachments— additive optional (без breaking change); удаление/ переименование/смена типа полей — breaking (migration + rollout). Unknown-enumCategory→Other.- Logs:
ticket-create received/created(TicketId,Number,QueueId,Category; ФИО/телефон — маской,Body— не пишется); metrics:support_tickets_created_total{category,queue,result}, latency p95 < 500 мс,support_identity_lookup{result}; traces — OTel; dashboard Grafanapin-support; runbook —docs/06-services/support/runbooks/(будущий путь по конвенции).
Обратные ссылки
Автоссылки: где используется этот документ.
- API (2): POST /api/support/tickets — Создать обращение от имени пользователя, POST /api/support/tickets/{id}/messages — Ответ оператора / внутренняя заметка
- Use Cases (1): UC-SUP-001. Приём обращения и назначение оператора
- События (1): Event Contract: pin.support.ticket-created (TicketCreatedKafkaEvent)
- Дашборды (1): Панель Наблюдаемости: support-overview (эксплуатация)
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности: ticket.md, ticket-message.md, ticket-queue.md; событие — ../../events/ticket-created.md; консюмер реплики — ../../consumers/identity-user-upserted.md.
- Смежные операции: get-my-ticket, list-my-tickets, add-ticket-message; заведение оператором — ../support/create-ticket-on-behalf.md.
- Контекст: README.