Entity / Aggregate: Selection (+ child SelectionItem)
Дом полей подборки объектов недвижимости контекста selections. Все проекции (модели API,
payload Kafka-событий, ссылки из CRM-лида/сделки, блок поста news) ссылаются сюда и перечисляют
только дельту.
Назначение
Selection — именованный упорядоченный набор объектов недвижимости с владельцем: создаётся вручную
или из текущего избранного владельца (internal API favorites), редактируется и удаляется только
владельцем, но открывается по публичной ссылке (по id/коду) кем угодно, включая гостя. Агент
отправляет подборки клиенту в лиде/сделке (CRM хранит ссылку на SelectionId). Для вставки в
пост news создаётся иммутабельная копия — selection-snapshot.md (INV-SEL-001).
| Metadata | Значение |
|---|---|
| Owning team | PIN Engagement |
| Статус документа | draft |
| Область действия | контекст selections, таблицы selections.selections, selections.selection_items |
Классификация
| Поле | Значение |
|---|---|
| Контекст | selections |
| Kind | AggregateRoot (Selection) + child Entity (SelectionItem) в границе агрегата |
| Source of truth | selections (этот контекст) |
| Таблицы | selections.selections, selections.selection_items |
| Tenant isolation | не tenanted — подборка личная (владелец — инвестор или агент), вне tenant по ADR-0052 п.2; изоляция record-level по OwnerUserId |
Жизненный цикл подборки — Active/Deleted — покрывается признаком soft delete DeletedAt (отдельный
enum статуса избыточен, ADR-0056). Soft delete применяется к Selection (на неё ссылаются CRM и
snapshot-ы — физическое удаление ломало бы ссылки), но не к SelectionItem (состав меняется физически,
история состава живёт в snapshot-ах). Параллельное редактирование состава/названия защищено
оптимистической блокировкой.
1. Selection — поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор подборки; на него ссылаются CRM (лид/сделка) и snapshot-ы. |
OwnerUserId | Guid | Да | index ix_selections_owner_user_id; логическая ссылка identity.users(id) — без физической FK (чужой контекст) | JWT (claim sub) | Владелец (инвестор или агент). Только владелец меняет/удаляет (INV-SEL-002). Гость подборки не создаёт (в отличие от избранного). |
Title | string | Да | varchar(256), 1..256 после trim | app (форма) | Название подборки. |
Description | string? | Нет | varchar(2000) | app (форма) | Необязательное описание для публичной страницы. |
PublicCode | string | Да | varchar(16), unique ux_selections_public_code; 12 символов base62, CSPRNG (~71 бит) | app (генерация при создании) | Публичный код ссылки /collection/{publicCode} — открывает кто угодно. Не последовательный Guid: перебор/угадывание чужих подборок неосуществим. Неизменяем (INV-SEL-005). |
Source | SelectionSource | Да | enum, хранится как text, varchar(16) | app | Как создана: Manual / Favorites (из текущего избранного). Аудит-поле, на поведение не влияет. |
CreatedAt | DateTimeOffset | Да | index ix_selections_owner_created (owner_user_id, created_at desc) — список «мои подборки» | app/SDK | Создание. |
UpdatedAt | DateTimeOffset | Да | — | app/SDK | Последняя правка (название/описание/состав). |
DeletedAt | DateTimeOffset? | Нет | partial-index ix_selections_public_code_active where deleted_at is null | app | Soft delete: удалённая подборка исчезает из списков и публичного доступа, но строки/ссылки сохраняются. |
Version | long | Да | default 0; not-null | app | Монотонный счётчик добавлений элементов за всю жизнь подборки (инкремент на каждое добавление элемента). Присваивается событию selection-item-added как порядковый Version — упорядочивает поэлементные события одной подборки, в т.ч. внутри одного батча замены состава. Не оптимистическая блокировка и не позиция (SortOrder). |
Enum SelectionSource
| Значение | Описание |
|---|---|
Manual | Создана вручную: владелец передал Title (+ опционально начальный список propertyObjectId[]). |
Favorites | Создана «из текущего избранного»: состав скопирован из favorites на момент создания (включая заметки → Comment), дальше живёт независимо от избранного. |
2. SelectionItem — поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор элемента. |
SelectionId | Guid | Да | FK → selections.selections(id) cascade; входит в unique ux_selection_items_selection_property_object | app | Родительская подборка (граница агрегата — правки только через корень). |
PropertyObjectId | Guid | Да | входит в ux_selection_items_selection_property_object (selection_id, property_object_id); логическая ссылка на объект недвижимости chessboard/catalog — без FK | app / копия из Favorite.PropertyObjectId | Объект недвижимости подборки. Существование синхронно не проверяется (INV-SEL-008, симметрично INV-FAV-004). |
SortOrder | int | Да | >= 0; уникальность в рамках подборки — app-уровень (нормализация 0..N-1 при каждой записи состава, INV-SEL-006) | app (порядок владельца) | Позиция в подборке. |
Comment | string? | Нет | varchar(4000), check chk_selection_items_comment_len | app / копия Favorite.Note при Source = Favorites | Комментарий владельца к объекту недвижимости внутри подборки (виден всем, кто открыл подборку по ссылке, — владелец решает, что писать; заметка избранного копируется по явному флагу copyNotes, по умолчанию false). |
CreatedAt | DateTimeOffset | Да | — | app/SDK | Добавление элемента. |
Value objects
Value objects не используются: обе сущности плоские, все поля скалярные.
Модели JSONB
JSONB-модели не используются (typed jsonb есть только у snapshot-а — selection-snapshot.md).
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|---|---|---|---|
Selection → SelectionItem | owned (граница агрегата) | SelectionId | агрегат всегда грузится целиком: ≤ 200 элементов (INV-SEL-004) | cascade (FK) |
Selection → UserEntity (identity) | логическая reference | OwnerUserId | не загружается; record-level фильтр по actor | — |
SelectionItem → PropertyObject (chessboard/catalog) | логическая reference | PropertyObjectId | display-данные добирает фронт батчем в catalog | — (недоступный объект недвижимости отображается с признаком недоступности) |
Selection ← SelectionSnapshot | reference (внутри контекста) | SourceSelectionId | selection-snapshot.md | restrict (snapshot живёт и после soft-delete подборки) |
Selection ← CRM (лид/сделка) | логическая внешняя ссылка | SelectionId | CRM хранит id и открывает по internal/public API | — (soft delete сохраняет разрешимость ссылок) |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
| INV-SEL-002 OwnerOnlyMutation | Создание/правка/удаление — только OwnerUserId = actor.UserId; чтение по PublicCode — любой актор (guest включительно) | при мутациях — по владельцу-актору; чтение по коду — без фильтра владельца | SELECTIONS_NOT_FOUND (404, чужое существование не раскрывается) |
| INV-SEL-003 UniquePropertyObjectPerSelection | Объект недвижимости входит в подборку не более одного раза | при замене состава / добавлении (дедупликация) + unique ux_selection_items_selection_property_object | SELECTIONS_DUPLICATE_PROPERTY_OBJECT |
| INV-SEL-004 ItemsLimit | ≤ 200 элементов в подборке | при замене состава / добавлении (проверка размера) | SELECTIONS_ITEMS_LIMIT_EXCEEDED |
| INV-SEL-005 PublicCodeImmutable | PublicCode генерируется при создании и никогда не меняется (ссылки, разосланные клиентам, не протухают) | операции изменения кода нет; коллизия unique при вставке → регенерация и повтор (до 3 раз) | — |
| INV-SEL-006 SortOrderNormalized | После любой записи состава SortOrder элементов — плотная последовательность 0..N-1 без дублей | при замене состава (перенумерация) | — |
| INV-SEL-007 OwnerSelectionsLimit | ≤ 100 не удалённых подборок на владельца | при создании — подсчёт не удалённых подборок владельца | SELECTIONS_LIMIT_EXCEEDED |
| INV-SEL-008 PropertyObjectIdTrusted | Существование PropertyObjectId синхронно не проверяется (ADR-0056); фильтрация на выдаче | проверка формата (Guid) | ValidationError |
| INV-SEL-009 DeletedIsReadOnly | Подборка с DeletedAt != null не читается публично, не редактируется, из неё нельзя создать snapshot | во всех операциях — условие DeletedAt == null | SELECTIONS_NOT_FOUND (404) |
| INV-SEL-010 FavoritesCopyIsDetached | Создание из избранного — одноразовое копирование состава (и заметок при copyNotes = true); последующие изменения избранного подборку не меняют и наоборот | при создании из избранного (без подписок/синхронизации) | — |
| INV-SEL-001 | Иммутабельность копии для поста — дом инварианта: selection-snapshot.md | — | — |
Переходы состояний (полный перечень)
Жизненный цикл — две позиции (Active = DeletedAt == null, Deleted = DeletedAt != null); правки
не меняют позицию, но фиксируются как факт. Инициатор всех переходов — владелец (OwnerUserId = actor,
INV-SEL-002), кроме служебного восстановления.
| From | To | Условие / триггер | Инициатор | Факты / события |
|---|---|---|---|---|
[*] | Active | Создание вручную или из избранного; лимиты пройдены (INV-SEL-004, 005, 007); сгенерирован неизменяемый PublicCode | Владелец | SelectionCreatedEvent → selection-created.v1 (+ N× item-added при непустом составе) |
Active | Active | Правка названия/описания (фактическое изменение; no-op с теми же значениями события не даёт) | Владелец | selection-updated.v1 |
Active | Active | Замена состава/порядка целиком (дедуп INV-SEL-003, лимит INV-SEL-004, нормализация 0..N-1 INV-SEL-006) | Владелец | selection-updated.v1 + N× selection-item-added.v1 (только новые PropertyObjectId из диффа) |
Active | Active | Отправка подборки клиенту (POST /selections/{id}/share); состав не меняется | Владелец-агент | SelectionSharedEvent → selection-shared.v1 |
Active | Deleted | Soft delete (DeletedAt = now); строки и элементы сохраняются | Владелец | selection-deleted.v1 |
Deleted | Deleted | Повторный DELETE уже удалённой — идемпотентный успех без перехода и без события (INV-SEL-009) | Владелец | — |
Deleted | Active | Восстановление снятием DeletedAt — только вручную runbook-ом (../README.md, Runbooks); пользовательского restore нет | Оператор | — (событие selection-restored.v1 — additive при появлении операции) |
Снятие snapshot-а (news, m2m) состояние подборки не меняет — это отдельный агрегат
(selection-snapshot.md); переход Active → Deleted не каскадит в snapshot-ы
(FK restrict, INV-SNP-006) и не блокируется их существованием.
Доменные и интеграционные события
Интеграционные события публикуются атомарно со сменой состояния агрегата.
| Событие | Тип | Топик | Когда | Snapshot/поля |
|---|---|---|---|---|
SelectionCreatedEvent | доменное | — | Создание подборки | SelectionId, OwnerUserId, Source, ItemsCount |
SelectionItemsChangedEvent | доменное | — | Замена состава изменила состав/порядок | SelectionId, ItemsCount |
SelectionItemAddedEvent | доменное | — | Добавлен новый элемент (каждый новый PropertyObjectId из диффа замены состава и каждый скопированный при Source = Favorites); удаление/переупорядочивание событие не поднимает | SelectionId, SelectionItemId, PropertyObjectId, OwnerUserId, ItemsCount, Version (пост-инкремент) |
SelectionDeletedEvent | доменное | — | Soft delete | SelectionId, OwnerUserId |
SelectionSharedEvent | доменное | — | Владелец-агент отправил подборку клиенту (POST /selections/{id}/share, INV-SEL-002/009); состав не меняется | SelectionId, OwnerUserId (агент), InvestorUserId, ItemsCount, Action = SelectionShareAction.Shared, OccurredAt |
SelectionCreatedKafkaEvent | интеграционное (ContractVersion = 1) | pin.selections.selection-created.v1 | Создание подборки | SelectionId, OwnerUserId, Title, Source, ItemsCount, OccurredAt. Потребители: user-activity (ActionEvent «создал подборку» → карточка лида CRM, ADR-0054) |
SelectionUpdatedKafkaEvent | интеграционное (ContractVersion = 1) | pin.selections.selection-updated.v1 | Правка названия/описания/состава | SelectionId, OwnerUserId, ItemsCount, OccurredAt. Потребители: user-activity |
SelectionItemAddedKafkaEvent | интеграционное (ContractVersion = 1, key = SelectionId) | pin.selections.selection-item-added.v1 | Поэлементно: каждый новый объект недвижимости в подборке (дифф замены состава, from-favorites); батч из N новых → N событий | SelectionId, SelectionItemId, PropertyObjectId, OwnerUserId, SelectionItemsCount?, OccurredAt, Version. Потребители: dev-stats (этап Selections воронки), user-activity (AddToSelection). Дом контракта — events/selection-item-added.md |
SelectionDeletedKafkaEvent | интеграционное (ContractVersion = 1) | pin.selections.selection-deleted.v1 | Soft delete | SelectionId, OwnerUserId, OccurredAt. Потребители: user-activity; CRM при открытии удалённой ссылки показывает «подборка удалена» (событие не обязано доходить — API вернёт 404) |
SelectionSharedKafkaEvent | интеграционное (ContractVersion = 1, key = SelectionId) | pin.selections.selection-shared.v1 | Отправка подборки клиенту | SelectionId, SelectionTitle, PropertyObjectsCount, Action (SelectionShareAction), AgentUserId (= OwnerUserId), InvestorUserId, OccurredAt. Потребители: crm (лента лида SelectionAttached), user-activity («отправил подборку»). Дом контракта — events/selection-shared.md |
Enum SelectionShareAction (Shared/Opened) — дискриминатор действия события selection-shared;
хранимым полем Selection не является, producer публикует Shared (агент отправил), Opened
зарезервирован — дом events/selection-shared.md.
Comment элементов и полный список PropertyObjectId в интеграционные события не включаются (объём/приватность);
потребителю достаточно факта и счётчика — состав читается API по id. Исключение — selection-item-added: по
построению несёт ровно один добавленный PropertyObjectId (поэлементная гранулярность для воронки/ленты),
но без Comment и без остального состава.
Индексы, хранение, безопасность
| Аспект | Значение |
|---|---|
| Индексы | selections: PK; ux_selections_public_code unique btree(public_code); ix_selections_owner_created btree(owner_user_id, created_at desc); ix_selections_public_code_active partial where deleted_at is null (горячий путь публичного открытия). selection_items: PK; ux_selection_items_selection_property_object unique btree(selection_id, property_object_id) — покрывает и загрузку состава по selection_id |
| Партиционирование / retention | Нет (объёмы малы — ADR-0056). Soft-deleted подборки старше 2 лет без ссылающихся snapshot-ов может чистить редкий job (отложено — не требуется на старте) |
| Concurrency | Оптимистическая блокировка Selection: параллельная замена состава — второй запрос получает конфликт и перечитывает (правка не должна затирать чужую) |
| Permissions / PII | Мутации — self-scope владельца (без отдельных permissions); публичное чтение по PublicCode — аноним; ответ публичного чтения не содержит OwnerUserId/ФИО владельца (только Title/Description/состав); Comment виден всем открывшим ссылку — фронт предупреждает владельца при заполнении |
| Audit | CreatedAt/UpdatedAt/DeletedAt; факты create/update/delete — события в user-activity |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: SelectionSnapshot
- API (11): API контекста selections — индекс, DELETE /api/v1/selections/{id} — Удалить подборку (soft delete), GET /api/v1/internal/selections/{id} — Подборка для карточки клиента/сделки (m2m crm), GET /api/v1/internal/selections/by-owner/{ownerUserId} — Свои подборки инвестора (m2m crm), GET /api/v1/selections — Мои подборки, GET /api/v1/selections/{publicCode} — Открыть подборку по публичной ссылке, GET /internal/favorites — Чтение избранного пользователя (m2m для selections), POST /api/v1/selections — Создать подборку вручную, POST /api/v1/selections/from-favorites — Создать подборку из текущего избранного, PUT /api/v1/selections/{id} — Изменить название/описание подборки, PUT /api/v1/selections/{id}/items — Заменить состав и порядок подборки целиком
- Use Cases (5): UC-SEL-001. Создание подборки из текущего избранного, UC-SEL-002. Шаринг подборки по ссылке (SharePopover + реферальная ссылка), UC-SEL-003. Аноним открывает подборку по публичной ссылке, UC-SEL-004. Автор поста вставляет подборку в пост (иммутабельный snapshot блока), UC-SEL-005. Агент отправляет подборку клиенту в лиде/сделке
- События (6): События контекста selections: карта топиков и отклонённые события, Event Contract: pin.selections.selection-created (SelectionCreatedKafkaEvent), Event Contract: pin.selections.selection-deleted (SelectionDeletedKafkaEvent), Event Contract: pin.selections.selection-item-added (SelectionItemAddedKafkaEvent), Event Contract: pin.selections.selection-shared (SelectionSharedKafkaEvent), Event Contract: pin.selections.selection-updated (SelectionUpdatedKafkaEvent)
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
- index.md (1): Selections Service (Pin.Selections)
Связанные документы
- selection-snapshot.md — иммутабельная копия для поста (INV-SEL-001).
- ../README.md — границы, карта API (в т.ч.
POST /selections/from-favorites), зависимость от internal APIfavorites. - ../../favorites/domain/favorite.md — источник состава и заметок
при
Source = Favorites. - ADR-0052 (вне tenant), ADR-0056 (simplicity-first); glossary.md («Подборка»).
- Будущие пути:
docs/06-services/news/README.md(блок-подборка поста),docs/06-services/crm/README.md(подборка в лиде/сделке),docs/06-services/catalog/README.md(батч display-данных объектов недвижимости).