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

POST /api/public/support/tickets — Подать обращение

Публичная точка входа «форма обратной связи» (матрица 4.4): любой авторизованный пользователь заводит обращение в Службу заботы ПИН. Операция создаёт обращение TicketEntity вместе с первым публичным сообщением TicketMessageEntity, маршрутизирует тикет в очередь (TicketQueueEntity) и публикует интеграционное событие pin.support.ticket-created.v1.

Назначение

Принять форму обращения (тема, категория, текст, опционально связь со сделкой/объектом недвижимости и вложения) от авторизованного заявителя, детерминированно направить в очередь поддержки по категории и вернуть карточку созданного тикета для оптимистичного отображения. Owning-контекст — support, feature group ticket.

Metadata

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

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

ПроверкаЗначение
Actor typeUser (любой авторизованный заявитель)
Auth policy / Permissionsnone (own action — заявитель создаёт своё обращение); требуется лишь аутентификация
Scope (RBAC)не применяется (own action; support — platform single-tenant, ADR-0052)
Record-levelвладелец = RequesterUserId (актор); доступ к своим тикетам — get-my-ticket
Tenant isolationTenantId = PIN CompanyId — фиксируется сервером (платформенный tenant), не берётся из запроса; контекст заявителя — колонки RequesterCompanyId/RequesterRole
Auditcreate (CreatedByUserId = RequesterUserId); PII заявителя и Body — маска/усечение в логах

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/public/support/tickets
Success status201 (создано)
Idempotency headerне применяется (каждое обращение — отдельный тикет)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
RequesterUserIdidentity claims (JWT sub)GuidДане Guid.Empty; логическая ссылка identity.users(id)владелец записи
RequesterCompanyIdidentity claimsGuid?Нетснапшот компании заявителя (контекст/фильтр)
RequesterRoleidentity claimsstring?Нет<=64; снапшот роли на момент обращения
SubjectbodystringДа1..256, не пустое после trim; индексируется в Elasticsearch (support-tickets)
CategorybodyTicketCategory (enum строкой)Нетзначение enum ticket.md; неизвестное/пустое → Other (default)
BodybodystringДа1..8000, не пустое (текст первого сообщения)PII: в логи не пишется целиком
RelatedDealIdbodyGuid?Нетлогическая ссылка crm; синхронно не проверяется (граница контекста)
RelatedPropertyObjectIdbodyGuid?Нетлогическая ссылка chessboard; синхронно не проверяется
AttachmentsbodyIReadOnlyList<TicketAttachmentInput>?Нетnull/пусто — вложений нет; элемент — модель нижеметаданные; бинарь — storage/CDN

Модель TicketAttachmentInput (элемент Attachments)

Проецируется в jsonb TicketAttachment — дом полей ticket-message.md.

ПолеТипОбязательностьВалидация / Ограничения
FileIdGuidДассылка на бинарь в storage/CDN (пресайн выполнен клиентом заранее)
FileNamestringДа1..256
ContentTypestringДа1..128 (MIME)
SizeByteslongДа> 0 and <= 26_214_400 (25 МБ)

Модель ответа TicketCardModel

Проекция агрегата TicketEntity (дом полей) — здесь только возвращаемая дельта.

ПолеТипОбязательностьИсточникMaskingОписание
TicketIdGuidДаTicketEntity.IdИдентификатор тикета.
NumberlongДаTicketEntity.Number (PG sequence)Человекочитаемый номер (SUP-NNN).
SubjectstringДаTicketEntity.SubjectТема обращения.
CategoryTicketCategoryДаTicketEntity.CategoryТематика (enum строкой).
PriorityTicketPriorityДаTicketEntity.Priority= Queue.DefaultPriority.
StatusTicketStatusДаTicketEntity.StatusNew при создании.
QueueQueueRefModelДаTicketEntity.QueueId + TicketQueueEntity.NameОчередь маршрутизации (вложенная ref-модель { QueueId, QueueName }).
ChannelTicketChannelДаTicketEntity.ChannelWebForm.
RequesterNameFullName?Нетреплика identityИ***ФИО заявителя; null — реплика отложена консюмером (см. побочные).
CreatedAtDateTimeOffsetДаTicketEntity.CreatedAtМомент создания.
FirstResponseDueAtDateTimeOffsetДаTicketEntity.FirstResponseDueAtДедлайн первого ответа (SLA по Priority).
MessagesCountintДаTicketEntity.MessagesCount1 (первое сообщение).
FirstMessageTicketMessageModelДасозданное сообщение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) постранично.

Алгоритм

  1. Контекст и доступ. Операцию выполняет любой авторизованный заявитель (actor User); RequesterUserId берётся из JWT, RequesterCompanyId/RequesterRole — из снапшота claims. support — единый платформенный tenant (TenantId = PIN CompanyId, серверная константа). Permission не требуется (own action); неаутентифицированный запрос → UNAUTHENTICATED (401).
  2. Проверка входа. Subject 1..256, Body 1..8000 (оба не пустые после trim); Category — допустимое значение enum, иначе Other (default); каждый элемент AttachmentsFileName 1..256, ContentType 1..128, SizeBytes 1..26_214_400. Нарушение → VALIDATION_FAILED. Ссылки RelatedDealId/RelatedPropertyObjectId — логические (границы контекста), синхронно не проверяются.
  3. Маршрутизация в очередь. Очередь подбирается по категории: активная очередь с 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).
  4. Создание обращения (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: RequesterPublic; INV-MSG-5: MessagesCount = 1, LastMessageAt = now).
  5. Согласованность и события. Обращение и первое сообщение фиксируются в одном согласованном изменении; очередь при этом не меняется. Публикуется интеграционное 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.
  6. Результат. Возвращается карточка TicketCardModel (эхо FirstMessage, Queue = { QueueId, QueueName } резолвнутой очереди, RequesterName под маской) → HTTP 201.

Диаграмма

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

ТипДетали
Integration eventsTicketCreatedEventpin.support.ticket-created.v1 (ContractVersion = 1), транзакционный outbox; консюмер — notifications (оповестить дежурных очереди)
External callsidentity internal lookup реплики заявителя (timeout 2с, без retry; graceful degradation — отложенная реплика консюмером pin.identity.users.v1)
Cache / projections / searchElasticsearch-проекция support-tickets (search/tickets.md, ADR-0059) — upsert документа TicketSearchDocument проектором при создании обращения (near-real-time)
Notificationsопосредованно: notifications — консюмер ticket-created (дежурные операторы очереди)

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
UNAUTHENTICATED401нет/невалидный JWT (actor не User)«Требуется авторизация»no retry
VALIDATION_FAILED400Subject/Body/Attachments вне ограничений«Проверьте корректность полей формы»no retry
SUPPORT_QUEUE_NOT_CONFIGURED500нет активной 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; When POST /api/public/support/tickets; Then 201: создано обращение 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 создание; Then 201: тикет создан, 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 создание; Then VALIDATION_FAILED (400), ничего не создано.
  • AC-7 (не аутентифицирован). Given запрос без валидного JWT; When создание; Then UNAUTHENTICATED (401).

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

  • RelatedDealId/RelatedPropertyObjectId/Attachments — additive optional (без breaking change); удаление/ переименование/смена типа полей — breaking (migration + rollout). Unknown-enum CategoryOther.
  • 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 Grafana pin-support; runbook — docs/06-services/support/runbooks/ (будущий путь по конвенции).

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

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

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