Selections Service (Pin.Selections)
Назначение
selections владеет подборками объектов недвижимости: CRUD подборок владельца (инвестора или агента),
создание подборки из текущего избранного, публичное открытие по ссылке (коду) кем угодно и
иммутабельные копии подборок для постов news (INV-SEL-001: после публикации поста создатель
подборки не меняет её содержимое в посте, но автор поста может заменить блок целиком). Агент отправляет
подборки клиенту в лиде/сделке — CRM хранит ссылку на подборку.
Metadata
| Поле | Значение |
|---|---|
| Статус | draft |
| Owning team | PIN Engagement |
| Domain | Вовлечение (engagement) |
| Data classification | Internal (подборка публична по ссылке; PII нет — владелец наружу не раскрывается) |
| Runtime criticality | low (недоступность не блокирует каталог/сделки; ссылки в постах/CRM временно не открываются) |
Ownership
| Artifact | Owner/Rule |
|---|---|
| PostgreSQL schema | selections |
| API areas | public (CRUD владельца + открытие по коду любым актором), internal (snapshot-ы для news m2m; чтение подборки CRM-ом) |
| Owned entities | Selection, SelectionItem, SelectionSnapshot |
| Produced events | pin.selections.selection-created.v1, pin.selections.selection-updated.v1, pin.selections.selection-item-added.v1, pin.selections.selection-deleted.v1, pin.selections.selection-shared.v1 |
| Consumed events | нет (создание из избранного — синхронный internal API favorites; снятие snapshot-а — синхронный вызов от news) |
| Search indexes | none (списки — btree PostgreSQL, ADR-0056) |
| Analytics facts | none (просмотры публичных подборок трекает user-activity) |
Ответственность И Границы
- Делает: создать/переименовать/изменить состав/удалить (soft) подборку; создать из текущего избранного
(одноразовое копирование состава и — по флагу — заметок, INV-SEL-010); публичное открытие по
PublicCodeлюбым актором, включая гостя; серверная отправка подборки клиенту агентом (POST /selections/{id}/share→ событиеselection-shared, факт для ленты лида CRM и истории действий); лимиты (≤ 100 подборок, ≤ 200 объектов недвижимости); snapshot-ы для постов (create/detach, INV-SEL-001). - НЕ делает: display-данные объектов недвижимости (батч фронта/рендера в
catalog); реферальные ссылки шаринга (фронт комбинирует публичную ссылку подборки с реф-ссылкойreferral); посты и блоки (news— владеетPostIdи историей версий поста); владение связью «подборка ↔ лид/сделка» и её материализацию в таймлайн (crmхранитSelectionIdв событии лида и открывает по API; отправку в сделку делает самcrm— send-deal-selection.md);selectionsлишь публикует факт отправки в лид (selection-shared); историю действий (user-activity); PDF-экспорт (отложено, вернётся с отчётамиinvest-analytics). - Source of truth:
Selection,SelectionItem,SelectionSnapshot. - Реплики/проекции: не публикует snapshot-реплик; события несут id+счётчики (состав читается API).
Зависимости
| Зависимость | Тип | Зачем Нужна | Failure mode |
|---|---|---|---|
PostgreSQL (schema selections) | db | Все таблицы + outbox | Полный отказ сервиса |
| Kafka | queue | pin.selections.* через outbox | CRUD работает; лента действий user-activity отстаёт (outbox догонит) |
favorites | service (internal API GET /internal/favorites?userId=) | Состав при создании из избранного | POST /selections/from-favorites временно недоступен (SELECTIONS_FAVORITES_UNAVAILABLE); остальные операции работают |
identity | service (JWT) | Аутентификация владельца | Публичное чтение по коду работает; мутации — нет |
news | consumer (m2m, синхронно) | Снятие/detach snapshot-ов при работе с блоком поста | Публикация постов с блоком-подборкой блокируется на стороне news |
crm | consumer (m2m, синхронно) | Чтение подборки для карточки клиента/сделки | CRM показывает «подборка недоступна» |
catalog | service (используется фронтом/рендером, не этим сервисом) | Display-данные объектов недвижимости | Выдача отдаёт PropertyObjectId+комментарии без карточек |
Redis не используется (ADR-0056: PostgreSQL-only для selections).
Кросс-Сервисные Контракты
Что selections предоставляет (исходящие гарантии)
| Канал | Кому | Ключ / версия | Гарантия |
|---|---|---|---|
pin.selections.selection-created.v1 | user-activity | key = SelectionId, ContractVersion = 1 | at-least-once через outbox; порядок событий одной подборки сохранён |
pin.selections.selection-updated.v1 | user-activity | key = SelectionId | публикуется только при фактическом изменении (no-op — без события) |
pin.selections.selection-item-added.v1 | dev-stats, user-activity | key = SelectionId, поле Version (монотонный) | по одному на новый объект в диффе; Version упорядочивает добавления внутри батча |
pin.selections.selection-deleted.v1 | user-activity | key = SelectionId | at-least-once; для показа «удалена» CRM не полагается на событие (читает флаг по API) |
pin.selections.selection-shared.v1 | crm, user-activity | key = SelectionId, поле Action (SelectionShareAction) | факт отправки подборки клиенту → лента лида CRM (SelectionAttached) |
GET /internal/selections/{id} | crm (pull) | — | резолвит подборку по вечно-разрешимому id (soft delete + FK restrict); soft-deleted отдаётся с IsDeleted = true, не 404 |
GET /internal/snapshots/{id} | news (pull) | Content.ContractVersion | иммутабельный Content (INV-SEL-001); отдаётся и для Detached (INV-SNP-006) |
Все потребители обязаны быть идемпотентны по (SelectionId, SelectionItemId/Version) — повторная
доставка не задваивает записи. Comment и полный список PropertyObjectId в события не входят
(объём/приватность) — состав читается по API; исключение — selection-item-added несёт ровно один
добавленный PropertyObjectId.
Что selections ожидает (входящие зависимости)
| Источник | Канал | Ожидание | Поведение при отсутствии/устаревании |
|---|---|---|---|
favorites | GET /internal/favorites?userId= (sync, m2m) | элементы (PropertyObjectId, Note?, CreatedAt), уникальность «владелец+объект» (INV-FAV-002), Note ≤ 4000 (INV-FAV-003), порядок CreatedAt desc | таймаут 3 с + 1 retry → SELECTIONS_FAVORITES_UNAVAILABLE (503), деградация только from-favorites |
news | m2m вызовы snapshot create/detach/read (sync) | доверенный вызыватель; авторство поста и право вставки проверены до вызова (INV-SNP-003); PostId — source of truth news | не-news вызыватель → 403; PostId не валидируется на существование |
crm | GET /internal/selections/{id} (sync, m2m) | доверенный вызыватель; право агента на подборку в лиде проверено crm; SelectionId сохранён у crm | не-crm → 403; record-level по OwnerUserId снят намеренно |
identity | JWT | sub = OwnerUserId для мутаций и списка | нет/просрочен токен → 401; публичное чтение по коду работает без токена |
catalog | батч display-данных (использует фронт/рендер, не этот сервис) | карточки объектов по PropertyObjectId[] под своим гейтом (JWT/реф-cookie) | недоступный объект помечается на выдаче (существование в selections не проверяется, INV-SEL-008) |
Окна eventual-consistency (что видит пользователь в промежутке)
- Между ответом мутации (
2xx) и записью в истории действийuser-activity/ воронкеdev-stats— секунды (лаг outbox + консюмер). Сама подборка и её публичная ссылка функциональны сразу (read-your-writes из основной БД; реплик нет — ADR-0056). - При простое шины/консюмера подборка уже создана/изменена/удалена и функциональна; отстаёт только лента действий и воронка — они догоняют без потери (at-least-once + идемпотентность).
- Snapshot-ы постов от правок живой подборки никогда не пересматриваются (INV-SEL-001): пост показывает
замороженный состав; изменить его можно только заменой блока целиком (
news: создать новый snapshot → отвязать старый).
Проекты
src/services/selections/
Pin.Selections.Api/ # public (CRUD + открытие по коду) + internal (news/crm m2m)
Pin.Selections.Application/ # прикладная логика операций
Pin.Selections.Domain/ # Selection, SelectionItem, SelectionSnapshot, enums
Pin.Selections.Infrastructure/ # хранилище (PostgreSQL), outbox, клиент favorites
Pin.Selections.Background/ # нет постоянных job-ов (retention отложен)
Pin.Selections.Migrator/ # миграции схемы selections
Pin.Selections.Contracts/ # pin.selections.* event contracts
Основные Сущности
| Entity | Назначение | Source Of Truth | Документ |
|---|---|---|---|
Selection | Подборка: владелец, название, публичный код, soft delete | selections | domain/selection.md |
SelectionItem | Элемент подборки: объект недвижимости + порядок + комментарий (child агрегата) | selections | domain/selection.md |
SelectionSnapshot | Иммутабельная копия подборки для блока поста (INV-SEL-001) | selections | domain/selection-snapshot.md |
ER-обзор
API (карта областей; детали — в api/)
- Public (мутации — владелец по JWT; чтение по коду — любой актор, включая гостя):
GET /selections— мои подборки (OwnerUserId = actor,DeletedAt == null,CreatedAtdesc) →api/public/list-my-selections.md;POST /selections— создать вручную (title,description?,propertyObjectIds?) →api/public/create-selection.md;POST /selections/from-favorites— создать из текущего избранного (title,copyNotes?; состав — internal APIfavorites, INV-SEL-010) →api/public/create-selection-from-favorites.md;GET /selections/{publicCode}— публичное открытие (без владельца в ответе; 404 для удалённых, INV-SEL-009) →api/public/get-selection-by-code.md;PUT /selections/{id}— название/описание →api/public/update-selection.md;PUT /selections/{id}/items— состав и порядок целиком (дедупликация INV-SEL-003, нормализация INV-SEL-006, лимит INV-SEL-004) →api/public/set-selection-items.md;DELETE /selections/{id}— soft delete →api/public/delete-selection.md;POST /selections/{id}/share— агент отправляет подборку клиенту ({ investorUserId }; owner-only INV-SEL-002, не удалена INV-SEL-009) → доменноеSelectionSharedEvent+ событиеselection-shared.v1; дом операции — UC-SEL-005, контракт — events/selection-shared.md (будущий api-файл по конвенцииapi/public/share-selection.md).
- Admin: not applicable (модерация публичных подборок — при появлении жалоб, отложено; заглушек в ТЗ нет).
- Internal (m2m):
POST /internal/selections/{id}/snapshots— снять копию для поста (news) → алгоритм в domain/selection-snapshot.md;POST /internal/snapshots/{id}/detach— отвязать при замене блока/удалении поста (news);GET /internal/snapshots/{id}— контент snapshot-а для рендера поста (news);GET /internal/selections/{id}— подборка для карточки клиента/сделки (crm).
- Callbacks: not applicable.
Events И Consumers
| Type | Name | Topic | Назначение | Link |
|---|---|---|---|---|
| Produced | SelectionCreatedKafkaEvent | pin.selections.selection-created.v1 | ActionEvent «создал подборку» → история действий / карточка лида | events/selection-created.md |
| Produced | SelectionUpdatedKafkaEvent | pin.selections.selection-updated.v1 | ActionEvent «изменил подборку» | events/selection-updated.md |
| Produced | SelectionItemAddedKafkaEvent | pin.selections.selection-item-added.v1 | Поэлементно: объект недвижимости добавлен в подборку → воронка dev-stats (этап Selections) + ActionEvent AddToSelection user-activity | events/selection-item-added.md |
| Produced | SelectionDeletedKafkaEvent | pin.selections.selection-deleted.v1 | ActionEvent «удалил подборку» | events/selection-deleted.md |
| Produced | SelectionSharedKafkaEvent | pin.selections.selection-shared.v1 | Агент отправил подборку клиенту → лента лида CRM (SelectionAttached) + ActionEvent user-activity | events/selection-shared.md |
| Consumed | — | — | Нет consumed-событий (интеграции с favorites/news/crm — синхронные API); отклонённое событие snapshot-created — обоснование | events/README.md |
Consumer событий — user-activity (created/updated/deleted/item-added/shared →
../user-activity/consumers/selections-events.md),
dev-stats (item-added → воронка,
../dev-stats/consumers/selection-item-added.md) и
crm (shared → лента лида SelectionAttached,
../crm/consumers/selection-shared.md).
Runtime И Эксплуатация
| Область | Требование |
|---|---|
| Health checks | /health/live; /health/ready (PostgreSQL, Kafka-producer, доступность favorites — degraded, не fail); /health/startup (миграции) |
| SLO/SLA | публичное открытие по коду p99 < 150 мс (partial-index); CRUD p99 < 250 мс; from-favorites p99 < 700 мс (включая вызов favorites) |
| Dashboard | selections-overview: RPS public/internal, создания по Source, snapshot create/detach, outbox |
| Alerts | outbox-lag > 5 мин; рост 5xx internal-области (блокирует публикации news); всплеск 404 по кодам (сканирование) |
| Runbooks | застрявший outbox; восстановление случайно удалённой подборки (soft delete → снятие DeletedAt вручную); расследование «пост показывает не тот состав» (сверка SnapshotId блока ↔ snapshot) |
| Feature flags | selections.public-open.enabled (аварийное закрытие публичного доступа при абьюзе) |
Безопасность
- Actor types: guest (только
GET /selections/{publicCode}), user (self-scope CRUD), m2mnews(snapshot create/detach/read), m2mcrm(чтение подборки), system (outbox). - Permission model: мутации — self-scope владельца (INV-SEL-002) без отдельных permissions; internal —
authorization policy по m2m-клиенту (
news,crm); каталог permissions —../identity/domain/permission.md. - Sensitive data classes: PII нет;
OwnerUserIdне отдаётся в публичных ответах;Commentэлемента — публичен для открывших ссылку (фронт предупреждает владельца). - Audit:
CreatedAt/UpdatedAt/DeletedAt,Source; snapshot-ы append-only со статусом/причиной detach; действия — события вuser-activity. - Tenant isolation: сущности вне tenant (ADR-0052 п.2 — личные данные владельца; snapshot в составе
публичного поста — п.3); обязательный record-level фильтр по
OwnerUserIdв мутациях; тесты изоляции обязательны. - Trusted/untrusted boundary:
publicCode— недоверенный вход (непереборный: 12 base62, ~71 бит, rate-limit на 404);propertyObjectIds— недоверенные (INV-SEL-008).
Известные Ограничения И Assumptions
- Существование
PropertyObjectIdне проверяется синхронно (ADR-0056); недоступные объекты недвижимости помечает выдача каталога. - Контракты internal API
favoritesи блока постаnews(SnapshotIdв typed-jsonb блоке) — assumptions до написания соответствующих ТЗ (будущие путиdocs/06-services/favorites/api/,docs/06-services/news/domain/post.md); поведение согласовано на уровне границ (service-boundaries.md). - Цены/статусы объектов недвижимости в snapshot не замораживаются — пост показывает актуальные display-данные каталога; заморожен только состав/порядок/комментарии (осознанное решение: пост не должен вводить в заблуждение старой ценой).
- Модерация публичных подборок и жалобы — вне scope (появится со
support). - Гость подборки не создаёт (в отличие от избранного) — ТЗ привязывает подборку к пользователю-владельцу.
Связанные Документы
- Домены: selection · selection-snapshot.
- Смежные контексты: ../favorites/README.md (источник from-favorites);
будущие —
../news/README.md(блок-подборка),../crm/README.md(подборка в лиде/сделке),../user-activity/README.md(ActionEvent),../referral/README.md(реф-ссылка шаринга). - Решения: technical-assignment.md (решение №11), ADR-0052, ADR-0056; glossary.md («Подборка»), service-boundaries.md.