PUT /api/v1/selections/{id} — Изменить название/описание подборки
Правка метаданных подборки владельцем: Title и Description. Состав меняется отдельной операцией
set-selection-items.md; PublicCode неизменяем (INV-SEL-005).
Назначение
Владелец переименовывает подборку или правит описание. Уже снятые для постов snapshot-ы не меняются
(INV-SEL-001 — их Content.Title заморожен). Дом полей —
../domain/selection.md.
| Поле | Значение |
|---|
| Сервис/контекст | selections |
| API area | public |
| Feature group | selections |
| Статус | draft |
| Документ | docs/06-services/selections/api/public/update-selection.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (JWT) |
| Auth policy / Permissions | authenticated user; отдельных permissions нет (self-scope) |
| Scope (RBAC) | self |
| Record-level | OwnerUserId == actor.UserId в предикате загрузки (INV-SEL-002); чужая/несуществующая → 404 без раскрытия существования |
| Tenant isolation | не применяется — вне tenant (ADR-0052 п.2) |
| Audit | update: UpdatedAt + SelectionUpdatedKafkaEvent → user-activity |
HTTP-контракт
| Поле | Значение |
|---|
| Method | PUT |
| Route | /api/v1/selections/{id:guid} |
| Success status | 200 |
| Idempotency header | не применяется — PUT идемпотентен семантически (полная замена метаданных) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
Id | route | Guid | Да | != Guid.Empty | record-level фильтр по владельцу |
UserId | identity claims (sub) | Guid | Да | Required | — |
Title | body | string | Да | 1..256 после trim | — |
Description | body | string? | Нет | ≤ 2000 после trim; пустая строка → null (очистка описания) | — |
PublicCode, Source, OwnerUserId в запросе не принимаются — неизменяемые поля.
Модель ответа UpdateSelectionResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
Id | Guid | Да | Selection.Id | — | Подборка. |
Title | string | Да | Selection.Title после правки | — | Актуальное название. |
Description | string? | Нет | Selection.Description | — | Актуальное описание. |
UpdatedAt | DateTimeOffset | Да | Selection.UpdatedAt | — | Штамп правки. |
Алгоритм
- Контекст и доступ. Операцию выполняет авторизованный пользователь; править можно только
собственную подборку (по владельцу-актору).
- Проверка входа и предусловий.
Id непустой; Title 1..256 после trim; Description ≤ 2000 —
иначе VALIDATION_ERROR. Подборка должна существовать, принадлежать актору (INV-SEL-002) и быть не
удалённой (INV-SEL-009); иначе единый SELECTIONS_NOT_FOUND (существование чужой не раскрывается).
- Что читаем. Подборка по id и владельцу-актору, не удалённая (метаданные корня, состав не нужен).
- Бизнес-правила и переходы. Переход
Active → Active (переименование): присваиваются Title,
Description (после trim, пустое описание очищается), UpdatedAt. При полном совпадении значений —
no-op (событие не поднимается, ответ 200 с текущим состоянием); при изменении — фиксируется факт
правки метаданных.
- Что меняется. Обновляется корень подборки (элементы не затрагиваются) в одном согласованном
изменении. Конфликт с параллельной правкой (другая вкладка) →
SELECTIONS_CONCURRENCY_CONFLICT
(409), клиент перечитывает и повторяет.
- Результат. Модель
UpdateSelectionResult (Id, Title, Description, UpdatedAt) → 200.
Публикуемое интеграционное событие — в разделе «Побочные эффекты».
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Интеграционные события | SelectionUpdatedKafkaEvent / pin.selections.selection-updated.v1 / key = SelectionId (только при фактическом изменении) |
| Проекции / поиск | snapshot-ы постов сознательно не трогаются (INV-SEL-001) |
Интеграционные последствия (асинхронно, через outbox)
Событие уходит через outbox после коммита правки (at-least-once, key = SelectionId —
относительный порядок правок одной подборки сохраняется). Цепочка:
selection-updated → user-activity: фиксирует ActionEvent «изменил подборку» в истории действий
владельца; если владелец-агент, событие питает карточку лида CRM (ADR-0054).
- Окно eventual-consistency: между
200 и записью в истории действий — секунды (лаг outbox +
консюмер). Новое название владелец и открывшие ссылку видят сразу (read-your-writes из основной БД);
лента действий догоняет.
- No-op (значения не изменились) события не порождает — история действий не засоряется пустыми
правками; snapshot-ы постов от переименования никогда не меняются (INV-SEL-001 —
Content.Title
заморожен на момент снятия).
- Потребитель идемпотентен по
SelectionId (+ порядковый штамп события) — повторная доставка не
дублирует запись в истории.
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
VALIDATION_ERROR | 400 | Title/Description вне ограничений | «Некорректные данные подборки» | no retry |
SELECTIONS_NOT_FOUND | 404 | Нет записи / чужая (INV-SEL-002) / удалена (INV-SEL-009) | «Подборка не найдена» | no retry |
SELECTIONS_CONCURRENCY_CONFLICT | 409 | Конфликт оптимистической блокировки (параллельная правка) | «Подборка изменена в другой вкладке — обновите страницу» | retry после перечитывания |
UNAUTHORIZED | 401 | Нет/просрочен access-токен | — | retry после refresh |
Предусловия и постусловия
- Предусловия: актор авторизован (валидный JWT); подборка существует, принадлежит актору
(
OwnerUserId == actor.UserId, INV-SEL-002) и не удалена (DeletedAt == null, INV-SEL-009).
- Постусловия при
200 с изменением: Title/Description подборки равны новым значениям (после
trim, пустое описание → null); UpdatedAt продвинут; PublicCode, Source, OwnerUserId, состав
элементов и все ранее снятые snapshot-ы не изменились (INV-SEL-001/005); в outbox поставлено ровно
одно selection-updated (key = SelectionId).
- Постусловия при
200-no-op (значения совпали): состояние подборки не изменилось (UpdatedAt не
двигается), событий нет.
- Постусловия при отказе (400/401/404/409): подборка не изменена, событий нет.
Acceptance Criteria
- Given подборка владельца с
Title = "Черновик", When PUT с Title = "Для Ивановых", Then 200,
ответ содержит новое название и продвинутый UpdatedAt; поставлено selection-updated
(key = SelectionId).
- Given та же подборка, When
PUT с Description = "" (после trim пусто), Then 200, Description
очищено в null.
- Given подборка с текущими
Title/Description, When PUT с теми же значениями, Then 200-no-op:
UpdatedAt не меняется, событие не публикуется.
- Given
Title из одних пробелов (после trim пусто), When PUT, Then 400 VALIDATION_ERROR, подборка
не изменена.
- Given чужая или несуществующая подборка, When
PUT, Then 404 SELECTIONS_NOT_FOUND (существование
чужой не раскрывается, INV-SEL-002).
- Given soft-deleted подборка, When
PUT, Then 404 SELECTIONS_NOT_FOUND (INV-SEL-009).
- Given две параллельные правки из разных вкладок, When обе доходят, Then одна фиксируется, вторая →
409 SELECTIONS_CONCURRENCY_CONFLICT; проигравший перечитывает актуальное состояние и повторяет.
- Given подборка с уже снятым snapshot-ом поста, When владелец переименовал подборку, Then
Content.Title
snapshot-а остаётся прежним (INV-SEL-001) — пост показывает название на момент публикации.
- Given отсутствующий/просроченный токен, When
PUT, Then 401 UNAUTHORIZED.
Совместимость и наблюдаемость
- Новые optional поля — additive; прочее — breaking.
- Logs:
UserId, SelectionId, результат (без Title/Description); metrics:
selections_updated_total{kind="metadata"}; traces: span UpdateSelectionCommand.
Dashboard — selections-overview.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы