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

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.

Metadata

ПолеЗначение
Сервис/контекстselections
API areapublic
Feature groupselections
Статусdraft
Документdocs/06-services/selections/api/public/set-selection-items.md

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

ПроверкаЗначение
Actor typeUser (JWT)
Auth policy / Permissionsauthenticated user; отдельных permissions нет (self-scope)
Scope (RBAC)self
Record-levelOwnerUserId == actor.UserId в предикате загрузки (INV-SEL-002)
Tenant isolationне применяется — вне tenant (ADR-0052 п.2)
Auditupdate: UpdatedAt + SelectionUpdatedKafkaEventuser-activity; SelectionItemAddedKafkaEvent (per new item) → dev-stats (воронка) + user-activity (AddToSelection)

HTTP-контракт

ПолеЗначение
MethodPUT
Route/api/v1/selections/{id:guid}/items
Success status200
Idempotency headerне применяется — PUT полной замены идемпотентен семантически
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
IdrouteGuidДа!= Guid.Emptyrecord-level фильтр по владельцу
UserIdidentity claims (sub)GuidДаRequired
ItemsbodyIReadOnlyList<SetSelectionItemModel>Да0..200 элементов (INV-SEL-004; пустой список = очистить подборку); дубли PropertyObjectIdSELECTIONS_DUPLICATE_PROPERTY_OBJECTuntrusted input

SetSelectionItemModel (элемент body, порядок массива = целевой порядок)

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
PropertyObjectIdbodyGuidДа!= Guid.Empty; уникален в массиве (INV-SEL-003); существование не проверяется (INV-SEL-008)untrusted
Commentbodystring?Нет≤ 4000 после trim; пустая строка → nullпубличен для открывших ссылку — фронт предупреждает

Явный SortOrder в запросе не принимается: позиция = индекс в массиве Items (сервер нормализует в 0..N-1, INV-SEL-006 — рассинхронизация индексов невозможна by construction).

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

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаSelection.IdПодборка.
ItemsCountintДаItems.Count после заменыИтоговое число элементов.
UpdatedAtDateTimeOffsetДаSelection.UpdatedAtШтамп правки.

Полный состав в ответ не дублируется — фронт уже держит отправленный список; расхождение исключено идемпотентной семантикой PUT.

Алгоритм

  1. Контекст и доступ. Операцию выполняет авторизованный пользователь; менять состав можно только у собственной подборки (по владельцу-актору).
  2. Проверка входа и предусловий. 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).
  3. Что читаем. Подборка целиком (с элементами — граница агрегата, ≤ 200), принадлежащая актору и не удалённая. Не найдена / чужая / удалена → единый SELECTIONS_NOT_FOUND (INV-SEL-002/009).
  4. Бизнес-правила и переходы. Замена состава — diff по PropertyObjectId (у выживших элементов сохраняются их Id/CreatedAt — меньше churn и понятный аудит):
    • удалить — элементы, чей PropertyObjectId отсутствует в новом списке;
    • добавить — новые PropertyObjectId; на каждый новый элемент растёт счётчик Version и фиксируется факт добавления (события переупорядочивания/выживших не поднимаются);
    • обновить — у выживших Comment (пустая строка/null очищает) и позицию;
    • нормализация: после diff все SortOrder = 0..N-1 в порядке входного массива (INV-SEL-006);
    • при полном совпадении состава/порядка/комментариев — no-op (событий нет); иначе фиксируется агрегатный факт изменения состава плюс по одному факту добавления на каждый новый элемент.
  5. Что меняется. Элементы удаляются/обновляются/добавляются, у корня обновляется UpdatedAt — всё в одном согласованном изменении. Дубль на unique-индексе невозможен после проверки шага 2 (страховка — constraint). Конфликт с параллельной заменой состава → SELECTIONS_CONCURRENCY_CONFLICT (409), клиент перечитывает.
  6. Результат. Модель 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 (пост-инкремент на каждое добавление) — упорядочивает добавления даже внутри одного батча замены. Цепочки:

  1. selection-updateduser-activity: ActionEvent «изменил подборку» в истории действий владельца (для владельца-агента питает карточку лида CRM, ADR-0054). Публикуется только при фактическом изменении состава/порядка/комментариев.
  2. selection-item-added (по одному на новый PropertyObjectId из диффа) → двум потребителям:
    • dev-stats — засчитывает объект недвижимости на этапе Selections воронки застройщика;
    • user-activity — ActionEvent AddToSelection в истории действий. Удаление и переупорядочивание элементов не порождают selection-item-added (только добавление нового объекта) — воронка/лента не задваиваются на перестановках.
  3. No-op (состав/порядок/комментарии совпали с текущими) не порождает ни одного события.
  4. Окно eventual-consistency: между 200 и обновлением воронки/истории — секунды (лаг outbox + консюмеры). Итоговый состав владелец и открывшие ссылку видят сразу (read-your-writes из основной БД). Потребители идемпотентны по (SelectionId, SelectionItemId/Version) — повторная доставка не задваивает счётчики воронки.
  5. Snapshot-ы уже опубликованных постов не пересматриваются (INV-SEL-001) — замена состава живой подборки не влияет на замороженные копии; автор поста меняет содержимое только заменой блока целиком (../internal/create-selection-snapshot.md).

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_ERROR400Пустой PropertyObjectId / Comment > 4000«Некорректные элементы подборки»no retry
SELECTIONS_ITEMS_LIMIT_EXCEEDED400> 200 элементов (INV-SEL-004)«В подборке может быть не более 200 объектов»no retry
SELECTIONS_DUPLICATE_PROPERTY_OBJECT400Дубли PropertyObjectId в запросе (INV-SEL-003)«Объект уже есть в подборке»no retry
SELECTIONS_NOT_FOUND404Нет записи / чужая / удалена (INV-SEL-002/009)«Подборка не найдена»no retry
SELECTIONS_CONCURRENCY_CONFLICT409Конфликт оптимистической блокировки«Подборка изменена параллельно — обновите страницу»retry после перечитывания
UNAUTHORIZED401Нет/просрочен 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.

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

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

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