POST /api/v1/selections — Создать подборку вручную
Создание пустой или предзаполненной подборки владельцем: название, описание, опциональный начальный
список объектов недвижимости. Генерируется неизменяемый PublicCode публичной ссылки (INV-SEL-005).
Назначение
Владелец (инвестор или агент) создаёт подборку с нуля — например, агент собирает объекты недвижимости под запрос
клиента. Создание «из текущего избранного» — отдельная операция
create-selection-from-favorites.md. Дом полей —
../domain/selection.md.
| Поле | Значение |
|---|
| Сервис/контекст | selections |
| API area | public |
| Feature group | selections |
| Статус | draft |
| Документ | docs/06-services/selections/api/public/create-selection.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (JWT; гость подборки не создаёт — подборка привязана к владельцу) |
| Auth policy / Permissions | authenticated user; отдельных permissions нет (self-scope) |
| Scope (RBAC) | self: OwnerUserId := actor.UserId, id владельца из запроса не принимается |
| Record-level | новая запись создаётся строго на актора (INV-SEL-002) |
| Tenant isolation | не применяется — вне tenant (ADR-0052 п.2) |
| Audit | create: CreatedAt + SelectionCreatedKafkaEvent → user-activity |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/v1/selections |
| Success status | 201 (Location: /api/v1/selections/{publicCode}) |
| Idempotency header | не применяется (повтор создаёт вторую подборку — фронт дизейблит кнопку; лимит INV-SEL-007 ограничивает ущерб) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
UserId | identity claims (sub) | Guid | Да | Required | владелец — только actor |
Title | body | string | Да | 1..256 символов после trim (дом: Selection.Title) | — |
Description | body | string? | Нет | ≤ 2000 после trim; пустая строка → null | — |
PropertyObjectIds | body | IReadOnlyList<Guid>? | Нет | ≤ 200 (INV-SEL-004); валидные непустые Guid; дубли схлопываются (INV-SEL-003); существование объектов недвижимости не проверяется (INV-SEL-008) | untrusted input |
Модель ответа CreateSelectionResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
Id | Guid | Да | Selection.Id | — | Идентификатор созданной подборки. |
PublicCode | string | Да | Selection.PublicCode | — | Код публичной ссылки (12 base62), сразу пригоден для шаринга. |
Title | string | Да | Selection.Title | — | Название после trim. |
Source | string (enum SelectionSource) | Да | Selection.Source = Manual | — | Способ создания. |
ItemsCount | int | Да | Items.Count после дедупликации | — | Число элементов (0..200). |
CreatedAt | DateTimeOffset | Да | Selection.CreatedAt | — | Момент создания. |
Алгоритм
- Контекст и доступ. Операцию выполняет авторизованный пользователь (владелец); id владельца
берётся из токена, из запроса не принимается. Повтор создаёт вторую подборку (идемпотентности по
ключу нет — фронт дизейблит кнопку, ущерб ограничен лимитом INV-SEL-007).
- Проверка входа и предусловий.
Title 1..256 после trim; Description ≤ 2000; каждый
PropertyObjectId непустой, всего ≤ 200 (INV-SEL-004), дубли схлопываются (INV-SEL-003) — иначе
VALIDATION_ERROR. Лимит владельца: если у него уже 100 не удалённых подборок (INV-SEL-007) → отказ
SELECTIONS_LIMIT_EXCEEDED. Существование объектов недвижимости не проверяется (INV-SEL-008, ADR-0056).
- Бизнес-правила и маппинг. Сгенерировать неизменяемый
PublicCode (12 base62, CSPRNG,
INV-SEL-005). Создать подборку с Source = Manual, владелец — актор, Title/Description после
trim. Элементы добавляются в порядке запроса: SortOrder = 0..N-1 (INV-SEL-006), дубли отфильтрованы
(INV-SEL-003). Фиксируется факт создания подборки.
- Что меняется. Появляется подборка с элементами (в одном согласованном изменении). При крайне
маловероятной коллизии
PublicCode (unique) код регенерируется, до 3 попыток (INV-SEL-005), затем
инфраструктурная ошибка 500.
- Результат. Идентификатор,
PublicCode, Title, Source, число элементов, дата создания → 201
- заголовок
Location. Masking не требуется.
Публикуемое интеграционное событие — в разделе «Побочные эффекты».
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Интеграционные события | SelectionCreatedKafkaEvent / pin.selections.selection-created.v1 / key = SelectionId (одно на создание) |
При ручном создании поэлементные selection-item-added не публикуются даже с непустым начальным
списком — начальный состав отражён только агрегатным ItemsCount в selection-created. Поэлементная
гранулярность (нужная воронке dev-stats, этап Selections) возникает в операциях from-favorites
(create-selection-from-favorites.md) и замены состава
(set-selection-items.md).
Интеграционные последствия (асинхронно, через outbox)
Событие уходит через outbox после коммита подборки (at-least-once, key = SelectionId — порядок
событий одной подборки сохраняется). Цепочка:
selection-created → user-activity: фиксирует ActionEvent «создал подборку» в истории действий
владельца; если владелец-агент, событие питает карточку лида CRM (ADR-0054).
- Окно eventual-consistency: между
201 и появлением записи в истории действий — секунды (лаг outbox
- консюмер). В интерфейсе владельца подборка видна сразу (read-your-writes из основной БД,
list-my-selections.md); лента действий догоняет.
- При простое консюмера/шины подборка уже создана и функциональна (ссылка работает); отстаёт только
история действий — потребитель идемпотентен по
SelectionId (повторная доставка не дублирует запись).
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
VALIDATION_ERROR | 400 | Title/Description/PropertyObjectIds вне ограничений | «Некорректные данные подборки» | no retry |
SELECTIONS_LIMIT_EXCEEDED | 400 | У владельца уже 100 не удалённых подборок (INV-SEL-007) | «Достигнут лимит подборок (100)» | no retry |
UNAUTHORIZED | 401 | Нет/просрочен access-токен | — | retry после refresh |
Предусловия и постусловия
- Предусловия: актор авторизован (валидный JWT); у владельца < 100 не удалённых подборок
(INV-SEL-007).
- Постусловия при
201: существует ровно одна новая подборка Source = Manual с уникальным
неизменяемым PublicCode (INV-SEL-005) и ≤ 200 элементами, чьи SortOrder образуют плотную
последовательность 0..N-1 без дублей PropertyObjectId (INV-SEL-003/006); счётчик не удалённых
подборок владельца увеличился на 1; в outbox поставлено событие selection-created.
- Постусловия при любом отказе (400/401): подборка не создана,
PublicCode не выделен, событий нет.
Acceptance Criteria
- Given владелец с 5 подборками, When
POST с Title = "Для Ивановых" и propertyObjectIds = [A, B, C]
(уникальные), Then 201 с Location, ItemsCount = 3, Source = Manual, SortOrder A=0/B=1/C=2,
выдан PublicCode (12 base62); поставлено selection-created (key = SelectionId).
- Given тот же владелец, When
propertyObjectIds = [A, B, A], Then дубль A схлопнут → ItemsCount = 2
(INV-SEL-003), 201.
- Given владелец без начального списка, When
propertyObjectIds пуст или отсутствует, Then 201,
ItemsCount = 0 (пустая подборка допустима).
- Given
Title из одних пробелов (после trim пусто), When POST, Then 400 VALIDATION_ERROR, подборка
не создана.
- Given
propertyObjectIds содержит Guid.Empty (или невалидный Guid), When POST, Then 400 VALIDATION_ERROR (INV-SEL-008 проверяет формат, не существование), подборка не создана.
- Given
propertyObjectIds из 201 элемента, When POST, Then 400 VALIDATION_ERROR (INV-SEL-004).
- Given крайне маловероятная коллизия сгенерированного
PublicCode с существующим, When POST, Then код
регенерируется (до 3 попыток, INV-SEL-005) и подборка создаётся с уникальным кодом; при исчерпании
попыток — инфраструктурная 500 (подборка не создана).
- Given владелец уже со 100 не удалёнными подборками, When
POST, Then 400 SELECTIONS_LIMIT_EXCEEDED
(INV-SEL-007), 101-я не создаётся.
- Given отсутствующий/просроченный токен, When
POST, Then 401 UNAUTHORIZED.
- Given две одинаковые быстрые отправки формы, When обе доходят, Then создаются две подборки с
разными
PublicCode (идемпотентности по ключу нет; ущерб ограничен лимитом INV-SEL-007).
Совместимость и наблюдаемость
- Новые optional поля запроса/ответа — additive; прочее — breaking (migration + rollout).
- Logs:
UserId, SelectionId, ItemsCount (без Title и списка объектов недвижимости); metrics:
selections_created_total{source="manual"}, гистограмма длительности; traces: span приёма запроса →
span бизнес-обработки → span записи в хранилище. Dashboard — selections-overview; alert на
outbox-lag — ../README.md.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы