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

Selections Service (Pin.Selections)

Назначение

selections владеет подборками объектов недвижимости: CRUD подборок владельца (инвестора или агента), создание подборки из текущего избранного, публичное открытие по ссылке (коду) кем угодно и иммутабельные копии подборок для постов news (INV-SEL-001: после публикации поста создатель подборки не меняет её содержимое в посте, но автор поста может заменить блок целиком). Агент отправляет подборки клиенту в лиде/сделке — CRM хранит ссылку на подборку.

Metadata

ПолеЗначение
Статусdraft
Owning teamPIN Engagement
DomainВовлечение (engagement)
Data classificationInternal (подборка публична по ссылке; PII нет — владелец наружу не раскрывается)
Runtime criticalitylow (недоступность не блокирует каталог/сделки; ссылки в постах/CRM временно не открываются)

Ownership

ArtifactOwner/Rule
PostgreSQL schemaselections
API areaspublic (CRUD владельца + открытие по коду любым актором), internal (snapshot-ы для news m2m; чтение подборки CRM-ом)
Owned entitiesSelection, SelectionItem, SelectionSnapshot
Produced eventspin.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 indexesnone (списки — btree PostgreSQL, ADR-0056)
Analytics factsnone (просмотры публичных подборок трекает 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; отправку в сделку делает сам crmsend-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Полный отказ сервиса
Kafkaqueuepin.selections.* через outboxCRUD работает; лента действий user-activity отстаёт (outbox догонит)
favoritesservice (internal API GET /internal/favorites?userId=)Состав при создании из избранногоPOST /selections/from-favorites временно недоступен (SELECTIONS_FAVORITES_UNAVAILABLE); остальные операции работают
identityservice (JWT)Аутентификация владельцаПубличное чтение по коду работает; мутации — нет
newsconsumer (m2m, синхронно)Снятие/detach snapshot-ов при работе с блоком постаПубликация постов с блоком-подборкой блокируется на стороне news
crmconsumer (m2m, синхронно)Чтение подборки для карточки клиента/сделкиCRM показывает «подборка недоступна»
catalogservice (используется фронтом/рендером, не этим сервисом)Display-данные объектов недвижимостиВыдача отдаёт PropertyObjectId+комментарии без карточек

Redis не используется (ADR-0056: PostgreSQL-only для selections).

Кросс-Сервисные Контракты

Что selections предоставляет (исходящие гарантии)

КаналКомуКлюч / версияГарантия
pin.selections.selection-created.v1user-activitykey = SelectionId, ContractVersion = 1at-least-once через outbox; порядок событий одной подборки сохранён
pin.selections.selection-updated.v1user-activitykey = SelectionIdпубликуется только при фактическом изменении (no-op — без события)
pin.selections.selection-item-added.v1dev-stats, user-activitykey = SelectionId, поле Version (монотонный)по одному на новый объект в диффе; Version упорядочивает добавления внутри батча
pin.selections.selection-deleted.v1user-activitykey = SelectionIdat-least-once; для показа «удалена» CRM не полагается на событие (читает флаг по API)
pin.selections.selection-shared.v1crm, user-activitykey = 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 ожидает (входящие зависимости)

ИсточникКаналОжиданиеПоведение при отсутствии/устаревании
favoritesGET /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
newsm2m вызовы snapshot create/detach/read (sync)доверенный вызыватель; авторство поста и право вставки проверены до вызова (INV-SNP-003); PostId — source of truth newsне-news вызыватель → 403; PostId не валидируется на существование
crmGET /internal/selections/{id} (sync, m2m)доверенный вызыватель; право агента на подборку в лиде проверено crm; SelectionId сохранён у crmне-crm → 403; record-level по OwnerUserId снят намеренно
identityJWTsub = 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 deleteselectionsdomain/selection.md
SelectionItemЭлемент подборки: объект недвижимости + порядок + комментарий (child агрегата)selectionsdomain/selection.md
SelectionSnapshotИммутабельная копия подборки для блока поста (INV-SEL-001)selectionsdomain/selection-snapshot.md

ER-обзор

API (карта областей; детали — в api/)

  • Public (мутации — владелец по JWT; чтение по коду — любой актор, включая гостя):
    • GET /selections — мои подборки (OwnerUserId = actor, DeletedAt == null, CreatedAt desc) → api/public/list-my-selections.md;
    • POST /selections — создать вручную (title, description?, propertyObjectIds?) → api/public/create-selection.md;
    • POST /selections/from-favorites — создать из текущего избранного (title, copyNotes?; состав — internal API favorites, 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

TypeNameTopicНазначениеLink
ProducedSelectionCreatedKafkaEventpin.selections.selection-created.v1ActionEvent «создал подборку» → история действий / карточка лидаevents/selection-created.md
ProducedSelectionUpdatedKafkaEventpin.selections.selection-updated.v1ActionEvent «изменил подборку»events/selection-updated.md
ProducedSelectionItemAddedKafkaEventpin.selections.selection-item-added.v1Поэлементно: объект недвижимости добавлен в подборку → воронка dev-stats (этап Selections) + ActionEvent AddToSelection user-activityevents/selection-item-added.md
ProducedSelectionDeletedKafkaEventpin.selections.selection-deleted.v1ActionEvent «удалил подборку»events/selection-deleted.md
ProducedSelectionSharedKafkaEventpin.selections.selection-shared.v1Агент отправил подборку клиенту → лента лида CRM (SelectionAttached) + ActionEvent user-activityevents/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)
Dashboardselections-overview: RPS public/internal, создания по Source, snapshot create/detach, outbox
Alertsoutbox-lag > 5 мин; рост 5xx internal-области (блокирует публикации news); всплеск 404 по кодам (сканирование)
Runbooksзастрявший outbox; восстановление случайно удалённой подборки (soft delete → снятие DeletedAt вручную); расследование «пост показывает не тот состав» (сверка SnapshotId блока ↔ snapshot)
Feature flagsselections.public-open.enabled (аварийное закрытие публичного доступа при абьюзе)

Безопасность

  • Actor types: guest (только GET /selections/{publicCode}), user (self-scope CRUD), m2m news (snapshot create/detach/read), m2m crm (чтение подборки), 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.