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

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 teamPIN Engagement
Статус документаdraft
Область действияконтекст selections, таблицы selections.selections, selections.selection_items

Классификация

ПолеЗначение
Контекстselections
KindAggregateRoot (Selection) + child Entity (SelectionItem) в границе агрегата
Source of truthselections (этот контекст)
Таблицы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)ИсточникОписание
IdGuidДаPKappИдентификатор подборки; на него ссылаются CRM (лид/сделка) и snapshot-ы.
OwnerUserIdGuidДаindex ix_selections_owner_user_id; логическая ссылка identity.users(id) — без физической FK (чужой контекст)JWT (claim sub)Владелец (инвестор или агент). Только владелец меняет/удаляет (INV-SEL-002). Гость подборки не создаёт (в отличие от избранного).
TitlestringДаvarchar(256), 1..256 после trimapp (форма)Название подборки.
Descriptionstring?Нетvarchar(2000)app (форма)Необязательное описание для публичной страницы.
PublicCodestringДаvarchar(16), unique ux_selections_public_code; 12 символов base62, CSPRNG (~71 бит)app (генерация при создании)Публичный код ссылки /collection/{publicCode} — открывает кто угодно. Не последовательный Guid: перебор/угадывание чужих подборок неосуществим. Неизменяем (INV-SEL-005).
SourceSelectionSourceДаenum, хранится как text, varchar(16)appКак создана: Manual / Favorites (из текущего избранного). Аудит-поле, на поведение не влияет.
CreatedAtDateTimeOffsetДаindex ix_selections_owner_created (owner_user_id, created_at desc) — список «мои подборки»app/SDKСоздание.
UpdatedAtDateTimeOffsetДаapp/SDKПоследняя правка (название/описание/состав).
DeletedAtDateTimeOffset?Нетpartial-index ix_selections_public_code_active where deleted_at is nullappSoft delete: удалённая подборка исчезает из списков и публичного доступа, но строки/ссылки сохраняются.
VersionlongДаdefault 0; not-nullappМонотонный счётчик добавлений элементов за всю жизнь подборки (инкремент на каждое добавление элемента). Присваивается событию selection-item-added как порядковый Version — упорядочивает поэлементные события одной подборки, в т.ч. внутри одного батча замены состава. Не оптимистическая блокировка и не позиция (SortOrder).

Enum SelectionSource

ЗначениеОписание
ManualСоздана вручную: владелец передал Title (+ опционально начальный список propertyObjectId[]).
FavoritesСоздана «из текущего избранного»: состав скопирован из favorites на момент создания (включая заметки → Comment), дальше живёт независимо от избранного.

2. SelectionItem — поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappИдентификатор элемента.
SelectionIdGuidДаFK → selections.selections(id) cascade; входит в unique ux_selection_items_selection_property_objectappРодительская подборка (граница агрегата — правки только через корень).
PropertyObjectIdGuidДавходит в ux_selection_items_selection_property_object (selection_id, property_object_id); логическая ссылка на объект недвижимости chessboard/catalog — без FKapp / копия из Favorite.PropertyObjectIdОбъект недвижимости подборки. Существование синхронно не проверяется (INV-SEL-008, симметрично INV-FAV-004).
SortOrderintДа>= 0; уникальность в рамках подборки — app-уровень (нормализация 0..N-1 при каждой записи состава, INV-SEL-006)app (порядок владельца)Позиция в подборке.
Commentstring?Нетvarchar(4000), check chk_selection_items_comment_lenapp / копия Favorite.Note при Source = FavoritesКомментарий владельца к объекту недвижимости внутри подборки (виден всем, кто открыл подборку по ссылке, — владелец решает, что писать; заметка избранного копируется по явному флагу copyNotes, по умолчанию false).
CreatedAtDateTimeOffsetДаapp/SDKДобавление элемента.

Value objects

Value objects не используются: обе сущности плоские, все поля скалярные.

Модели JSONB

JSONB-модели не используются (typed jsonb есть только у snapshot-а — selection-snapshot.md).

Связи

СвязьТипКлючПравило загрузкиDelete behavior
Selection → SelectionItemowned (граница агрегата)SelectionIdагрегат всегда грузится целиком: ≤ 200 элементов (INV-SEL-004)cascade (FK)
Selection → UserEntity (identity)логическая referenceOwnerUserIdне загружается; record-level фильтр по actor
SelectionItem → PropertyObject (chessboard/catalog)логическая referencePropertyObjectIddisplay-данные добирает фронт батчем в catalog— (недоступный объект недвижимости отображается с признаком недоступности)
Selection ← SelectionSnapshotreference (внутри контекста)SourceSelectionIdselection-snapshot.mdrestrict (snapshot живёт и после soft-delete подборки)
Selection ← CRM (лид/сделка)логическая внешняя ссылкаSelectionIdCRM хранит 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_objectSELECTIONS_DUPLICATE_PROPERTY_OBJECT
INV-SEL-004 ItemsLimit≤ 200 элементов в подборкепри замене состава / добавлении (проверка размера)SELECTIONS_ITEMS_LIMIT_EXCEEDED
INV-SEL-005 PublicCodeImmutablePublicCode генерируется при создании и никогда не меняется (ссылки, разосланные клиентам, не протухают)операции изменения кода нет; коллизия 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 == nullSELECTIONS_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), кроме служебного восстановления.

FromToУсловие / триггерИнициаторФакты / события
[*]ActiveСоздание вручную или из избранного; лимиты пройдены (INV-SEL-004, 005, 007); сгенерирован неизменяемый PublicCodeВладелецSelectionCreatedEventselection-created.v1 (+ N× item-added при непустом составе)
ActiveActiveПравка названия/описания (фактическое изменение; no-op с теми же значениями события не даёт)Владелецselection-updated.v1
ActiveActiveЗамена состава/порядка целиком (дедуп INV-SEL-003, лимит INV-SEL-004, нормализация 0..N-1 INV-SEL-006)Владелецselection-updated.v1 + N× selection-item-added.v1 (только новые PropertyObjectId из диффа)
ActiveActiveОтправка подборки клиенту (POST /selections/{id}/share); состав не меняетсяВладелец-агентSelectionSharedEventselection-shared.v1
ActiveDeletedSoft delete (DeletedAt = now); строки и элементы сохраняютсяВладелецselection-deleted.v1
DeletedDeletedПовторный DELETE уже удалённой — идемпотентный успех без перехода и без события (INV-SEL-009)Владелец
DeletedActiveВосстановление снятием 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 deleteSelectionId, 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.v1Soft deleteSelectionId, 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 виден всем открывшим ссылку — фронт предупреждает владельца при заполнении
AuditCreatedAt/UpdatedAt/DeletedAt; факты create/update/delete — события в user-activity

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

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

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

  • selection-snapshot.md — иммутабельная копия для поста (INV-SEL-001).
  • ../README.md — границы, карта API (в т.ч. POST /selections/from-favorites), зависимость от internal API favorites.
  • ../../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-данных объектов недвижимости).