PUT /api/v1/selections/{id}/items — Заменить состав и порядок подборки целиком
Полная замена списка элементов подборки владельцем: объекты недвижимости, порядок, комментарии — одним запросом.
Дедупликация (INV-SEL-003), нормализация SortOrder 0..N-1 (INV-SEL-006), лимит 200 (INV-SEL-004).
Назначение
Владелец добавляет/удаляет объекты недвижимости и перетаскивает порядок; фронт присылает итоговый
список целиком (модель «replace all» вместо гранулярных add/remove/reorder — один вызов, одно
согласованное изменение, нет расхождения порядка). Уже снятые snapshot-ы постов не меняются
(INV-SEL-001). Дом полей — ../domain/selection.md.
| Поле | Значение |
|---|
| Сервис/контекст | selections |
| API area | public |
| Feature group | selections |
| Статус | draft |
| Документ | docs/06-services/selections/api/public/set-selection-items.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (JWT) |
| Auth policy / Permissions | authenticated user; отдельных permissions нет (self-scope) |
| Scope (RBAC) | self |
| Record-level | OwnerUserId == actor.UserId в предикате загрузки (INV-SEL-002) |
| Tenant isolation | не применяется — вне tenant (ADR-0052 п.2) |
| Audit | update: UpdatedAt + SelectionUpdatedKafkaEvent → user-activity; SelectionItemAddedKafkaEvent (per new item) → dev-stats (воронка) + user-activity (AddToSelection) |
HTTP-контракт
| Поле | Значение |
|---|
| Method | PUT |
| Route | /api/v1/selections/{id:guid}/items |
| Success status | 200 |
| Idempotency header | не применяется — PUT полной замены идемпотентен семантически |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
Id | route | Guid | Да | != Guid.Empty | record-level фильтр по владельцу |
UserId | identity claims (sub) | Guid | Да | Required | — |
Items | body | IReadOnlyList<SetSelectionItemModel> | Да | 0..200 элементов (INV-SEL-004; пустой список = очистить подборку); дубли PropertyObjectId → SELECTIONS_DUPLICATE_PROPERTY_OBJECT | untrusted input |
SetSelectionItemModel (элемент body, порядок массива = целевой порядок)
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
PropertyObjectId | body | Guid | Да | != Guid.Empty; уникален в массиве (INV-SEL-003); существование не проверяется (INV-SEL-008) | untrusted |
Comment | body | string? | Нет | ≤ 4000 после trim; пустая строка → null | публичен для открывших ссылку — фронт предупреждает |
Явный SortOrder в запросе не принимается: позиция = индекс в массиве Items (сервер нормализует в
0..N-1, INV-SEL-006 — рассинхронизация индексов невозможна by construction).
Модель ответа SetSelectionItemsResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
Id | Guid | Да | Selection.Id | — | Подборка. |
ItemsCount | int | Да | Items.Count после замены | — | Итоговое число элементов. |
UpdatedAt | DateTimeOffset | Да | Selection.UpdatedAt | — | Штамп правки. |
Полный состав в ответ не дублируется — фронт уже держит отправленный список; расхождение исключено
идемпотентной семантикой PUT.
Алгоритм
- Контекст и доступ. Операцию выполняет авторизованный пользователь; менять состав можно только у
собственной подборки (по владельцу-актору).
- Проверка входа и предусловий.
Id непустой; Items.Count <= 200 (INV-SEL-004) иначе
SELECTIONS_ITEMS_LIMIT_EXCEEDED; каждый PropertyObjectId непустой, каждый Comment ≤ 4000 иначе
VALIDATION_ERROR; дубли PropertyObjectId в запросе (INV-SEL-003) →
SELECTIONS_DUPLICATE_PROPERTY_OBJECT. Существование объектов недвижимости не проверяется (INV-SEL-008,
ADR-0056).
- Что читаем. Подборка целиком (с элементами — граница агрегата, ≤ 200), принадлежащая актору и не
удалённая. Не найдена / чужая / удалена → единый
SELECTIONS_NOT_FOUND (INV-SEL-002/009).
- Бизнес-правила и переходы. Замена состава — diff по
PropertyObjectId (у выживших элементов
сохраняются их Id/CreatedAt — меньше churn и понятный аудит):
- удалить — элементы, чей
PropertyObjectId отсутствует в новом списке;
- добавить — новые
PropertyObjectId; на каждый новый элемент растёт счётчик Version и
фиксируется факт добавления (события переупорядочивания/выживших не поднимаются);
- обновить — у выживших
Comment (пустая строка/null очищает) и позицию;
- нормализация: после diff все
SortOrder = 0..N-1 в порядке входного массива (INV-SEL-006);
- при полном совпадении состава/порядка/комментариев — no-op (событий нет); иначе фиксируется
агрегатный факт изменения состава плюс по одному факту добавления на каждый новый элемент.
- Что меняется. Элементы удаляются/обновляются/добавляются, у корня обновляется
UpdatedAt — всё
в одном согласованном изменении. Дубль на unique-индексе невозможен после проверки шага 2 (страховка
— constraint). Конфликт с параллельной заменой состава → SELECTIONS_CONCURRENCY_CONFLICT (409),
клиент перечитывает.
- Результат. Модель
SetSelectionItemsResult (Id, итоговое число элементов, UpdatedAt) → 200.
Публикуемые интеграционные события — в разделе «Побочные эффекты».
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Интеграционные события | SelectionUpdatedKafkaEvent / pin.selections.selection-updated.v1 / key = SelectionId (только при изменении) + SelectionItemAddedKafkaEvent / pin.selections.selection-item-added.v1 / key = SelectionId — по одному на каждый новый объект недвижимости в диффе |
| Проекции / поиск | snapshot-ы постов не трогаются (INV-SEL-001 — пост продолжает показывать замороженный состав) |
Интеграционные последствия (асинхронно, через outbox)
Все события уходят через outbox после коммита замены (at-least-once). Общий key = SelectionId
сохраняет относительный порядок событий одной подборки; поэлементные selection-item-added дополнительно
несут монотонный Version (пост-инкремент на каждое добавление) — упорядочивает добавления даже внутри
одного батча замены. Цепочки:
selection-updated → user-activity: ActionEvent «изменил подборку» в истории действий владельца
(для владельца-агента питает карточку лида CRM, ADR-0054). Публикуется только при фактическом
изменении состава/порядка/комментариев.
selection-item-added (по одному на новый PropertyObjectId из диффа) → двум потребителям:
dev-stats — засчитывает объект недвижимости на этапе Selections воронки застройщика;
user-activity — ActionEvent AddToSelection в истории действий.
Удаление и переупорядочивание элементов не порождают selection-item-added (только добавление
нового объекта) — воронка/лента не задваиваются на перестановках.
- No-op (состав/порядок/комментарии совпали с текущими) не порождает ни одного события.
- Окно eventual-consistency: между
200 и обновлением воронки/истории — секунды (лаг outbox +
консюмеры). Итоговый состав владелец и открывшие ссылку видят сразу (read-your-writes из основной БД).
Потребители идемпотентны по (SelectionId, SelectionItemId/Version) — повторная доставка не
задваивает счётчики воронки.
- Snapshot-ы уже опубликованных постов не пересматриваются (INV-SEL-001) — замена состава живой подборки
не влияет на замороженные копии; автор поста меняет содержимое только заменой блока целиком
(../internal/create-selection-snapshot.md).
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
VALIDATION_ERROR | 400 | Пустой PropertyObjectId / Comment > 4000 | «Некорректные элементы подборки» | no retry |
SELECTIONS_ITEMS_LIMIT_EXCEEDED | 400 | > 200 элементов (INV-SEL-004) | «В подборке может быть не более 200 объектов» | no retry |
SELECTIONS_DUPLICATE_PROPERTY_OBJECT | 400 | Дубли PropertyObjectId в запросе (INV-SEL-003) | «Объект уже есть в подборке» | no retry |
SELECTIONS_NOT_FOUND | 404 | Нет записи / чужая / удалена (INV-SEL-002/009) | «Подборка не найдена» | no retry |
SELECTIONS_CONCURRENCY_CONFLICT | 409 | Конфликт оптимистической блокировки | «Подборка изменена параллельно — обновите страницу» | retry после перечитывания |
UNAUTHORIZED | 401 | Нет/просрочен access-токен | — | retry после refresh |
Предусловия и постусловия
- Предусловия: актор авторизован (валидный JWT); подборка существует, принадлежит актору
(INV-SEL-002) и не удалена (INV-SEL-009); входной список ≤ 200 элементов без дублей
PropertyObjectId.
- Постусловия при
200 с изменением: состав подборки в точности равен входному списку; SortOrder
элементов — плотная последовательность 0..N-1 в порядке входного массива (INV-SEL-006) без дублей
PropertyObjectId (INV-SEL-003); у выживших элементов сохранены их Id/CreatedAt; UpdatedAt
продвинут; для каждого нового объекта Version увеличен и поставлено selection-item-added; при
фактическом изменении поставлено одно selection-updated; ранее снятые snapshot-ы не изменены
(INV-SEL-001).
- Постусловия при пустом входном списке: все элементы удалены, подборка существует и пуста
(
ItemsCount = 0), selection-updated опубликовано (если раньше состав был непуст).
- Постусловия при
200-no-op (полное совпадение): состав не изменён, событий нет.
- Постусловия при отказе (400/401/404/409): состав подборки не изменён, событий нет.
Acceptance Criteria
- Given подборка владельца с элементами [A, B], When
PUT items = [{A}, {C}, {B}], Then 200,
ItemsCount = 3, SortOrder A=0/C=1/B=2; для нового C — одно selection-item-added
(key = SelectionId, с Version); поставлено одно selection-updated.
- Given та же подборка [A, B], When
PUT items = [{B}, {A}] (только перестановка), Then 200,
SortOrder B=0/A=1; selection-item-added не публикуется (новых объектов нет); selection-updated
публикуется (порядок изменился).
- Given подборка [A, B, C], When
PUT items = [{A}], Then 200, ItemsCount = 1, B и C удалены;
selection-item-added не публикуется; selection-updated публикуется.
- Given подборка [A], When
PUT items = [] (пустой), Then 200, ItemsCount = 0 (подборка очищена).
- Given
PUT items = [{A}, {A}] (дубль в запросе), Then 400 SELECTIONS_DUPLICATE_PROPERTY_OBJECT
(INV-SEL-003), состав не изменён.
- Given
PUT items из 201 элемента, Then 400 SELECTIONS_ITEMS_LIMIT_EXCEEDED (INV-SEL-004).
- Given элемент с
Comment длиной > 4000, Then 400 VALIDATION_ERROR.
- Given
PUT items, полностью совпадающий с текущим составом/порядком/комментариями, Then 200-no-op:
UpdatedAt не двигается, событий нет.
- Given чужая / несуществующая / удалённая подборка, When
PUT, Then 404 SELECTIONS_NOT_FOUND
(INV-SEL-002/009).
- Given две параллельные замены состава одной подборки, When обе доходят, Then одна фиксируется, вторая →
409 SELECTIONS_CONCURRENCY_CONFLICT; проигравший перечитывает актуальный состав и повторяет.
- Given отсутствующий/просроченный токен, When
PUT, Then 401 UNAUTHORIZED.
Совместимость и наблюдаемость
- Новые optional поля элемента — additive; смена семантики порядка (индекс массива) — breaking.
- Logs:
UserId, SelectionId, ItemsCount, размеры diff (added/removed/updated — счётчики, без
PropertyObjectId-списков и комментариев); metrics: selections_updated_total{kind="items"},
selections_items_count (histogram); traces: span SetSelectionItemsCommand.
Dashboard — selections-overview.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы