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

UC-SEL-005. Агент отправляет подборку клиенту в лиде/сделке

Назначение

Описывает сценарий, в котором агент — владелец подборки отправляет свою подборку клиенту в карточке лида CRM как канал привлечения и работы (ТехЗадание.md:277–283; prototype-mapping: «агент отправляет подборки клиенту; CRM хранит ссылку на подборку»). Со стороны selections сценарий выполняет одну серверную операциюPOST /selections/{id}/share (агент отправил подборку клиенту): она фиксирует доменный факт SelectionSharedEvent и публикует pin.selections.selection-shared.v1 (факт для ленты лида crm и истории действий user-activity), но состав подборки не меняет. Владение связью «подборка ↔ лид» и её материализацию в таймлайн (LeadEvent SelectionAttached) держит crm — потребитель события; резолв подборки для карточки — синхронный internal API. Связан с контекстом selections; интеграция — crm, referral, user-activity, catalog.

Metadata

ПолеЗначение
IDUC-SEL-005
Контекстselections (см. docs/06-services/selections/); интеграция — crm, referral, user-activity, catalog
Типосновной
Статусdraft

Актор

ПолеЗначение
РольАгент — владелец подборки (UserEntity.Type = Agent); отправка подборки клиенту = канал привлечения/работы в лиде/сделке
Permissions / scopeself-scope на selections: OwnerUserId = actor.UserId (INV-SEL-002), отдельных permissions нет; прикрепление подборки к лиду/сделке — операция и permission crm (не selections)
Tenantподборка вне tenant (ADR-0052 п.2 — личные данные владельца); лид/сделка агента — в tenant агентства (TenantId = CompanyId) на стороне crm
Record-levelGET /selections фильтруется по OwnerUserId = actor.UserId (чужую подборку выбрать нельзя); internal-резолв для CRM — по SelectionId (доверенный m2m crm, не привязан к record-level владельца)

Предусловие

  • У агента есть активная (не удалённая) подборка — создана из избранного или вручную (UC-SEL-001).
  • В CRM существует лид/сделка клиента (карточка /crm/clients/[id]).
  • selections доступен (PostgreSQL up); referral / user-activity / catalog — опциональные зависимости (graceful degradation — A2, A4; display-данные объектов недвижимости добирает фронт из catalog).

Триггер

ПолеЗначение
ИсточникUI button
Триггерящий элементРаздел «Подборки» карточки клиента CRM /crm/clients/[id]/collections — «отправить подборку»; фронт вызывает selections POST /selections/{id}/share (событие selection-shared → лента лида crm)

Основной Поток

  1. Агент в карточке клиента/сделки открывает /crm/clients/[id]/collections и выбирает свою подборку; фронт CRM берёт список GET /api/v1/selections (self-scope: только подборки владельца, не удалённые, по CreatedAt desc — INV-SEL-002, чужие не видны). В каждом MySelectionListItemModel есть Id и PublicCode (../api/public/list-my-selections.md).
  2. Агент нажимает «отправить подборку» выбранному клиенту — фронт CRM вызывает selections POST /api/v1/selections/{id}/share (JWT агента, тело { investorUserId } — id клиента карточки). Операция share (owner-only INV-SEL-002, не удалена INV-SEL-009; состав не меняется) публикует pin.selections.selection-shared.v1 (Action = Shared, AgentUserId = OwnerUserId, InvestorUserId, SelectionTitle, PropertyObjectsCount, OccurredAt) — атомарно с фиксацией операции share (внутренний доменный факт SelectionSharedEvent). Событие потребляют crm (дописывает LeadEvent SelectionAttached в ленту активного лида пары agent↔investor — crm/consumers/selection-shared.md) и user-activity (ActionEvent «отправил подборку»). Персистентную запись «подборка на лиде» держит именно лента лида crm (LeadSelectionPayload.SelectionId), не selections.
  3. Агент делится публичной ссылкой /collection/{publicCode} с клиентом: SharePopover комбинирует публичную ссылку подборки с реф-параметром referral — итог /collection/{publicCode}?ref={referralLinkId} (UC-SEL-002). Ссылка строится на фронте, без запроса в selections.
  4. Клиент открывает подборку по коду — UC-SEL-003: GET /api/v1/selections/{publicCode} без JWT (любой актор); в публичном ответе OwnerUserId не раскрывается (masking — ../domain/selection.md «Permissions / PII»).
  5. Резолв подборки для карточки CRM. CRM резолвит подборку через GET /api/v1/internal/selections/{id} (m2m crm, policy InternalCrmOnly; дом контракта — будущий ../api/internal/get-selection.md) — показывает Title/состав. Read-only на стороне selections:
    • Доступ. Вызыватель — доверенный m2m-клиент crm (policy InternalCrmOnly); id — непустой Guid, иначе ValidationError.
    • Поиск. Подборка ищется по id с элементами (граница агрегата, ≤ 200, INV-SEL-004). Предикат DeletedAt == null не накладывается: CRM хранит SelectionId и обязан различать «удалена» и «не существует вовсе». Не найдена → SELECTIONS_NOT_FOUND (404).
    • Проекция InternalSelectionResult (дельта дома полей ../domain/selection.md): SelectionId, Title, Description, PublicCode, Items в порядке SortOrder (PropertyObjectId, SortOrder, Comment), IsDeleted = (DeletedAt != null). Для soft-deleted подборки IsDeleted = trueINV-SEL-009 (публичный 404) на internal-чтение crm не распространяется (soft delete сохраняет разрешимость ссылок для CRM/snapshot-ов). При IsDeleted = true CRM показывает «подборка недоступна/удалена», иначе — Title и состав.
  6. Просмотры/действия клиента по ссылке трекает user-activity (гостевая сессия X-Anonymous-Id, клиентский ActionEvent, ADR-0054) → таймлайн лида. Карточка лида CRM дополнительно обогащается ActionEvent «агент создал подборку» из pin.selections.selection-created.v1 (consumer user-activity../events/selection-created.md), опубликованного ранее при создании подборки (UC-SEL-001) — не в этом сценарии.

Промежуточное состояние (eventual consistency). POST /selections/{id}/share подтверждается синхронно (selections фиксирует факт и ставит событие в outbox); запись «подборка на лиде» (LeadEvent SelectionAttached) появляется в таймлайне crm только после потребления selection-shared.v1 (окно — SLA консюмера crm < 5 мин, ../events/selection-shared.md). До этого агент видит успех отправки, но лента лида ещё без записи — это ожидаемое промежуточное состояние. Повторная отправка той же подборки тому же клиенту — осознанно новый факт: каждая операция share даёт своё событие (уникальный MessageId, свой OccurredAt) и свою запись в ленте лида — консюмер дубли одного события схлопывает по MessageId, но разные отправки во времени не объединяет.

Альтернативные Потоки

A1. Агент удалил подборку после прикрепления

  1. internal-резолв (без предиката DeletedAt == null) возвращает запись с IsDeleted = true → CRM показывает «подборка удалена» ([../api/public/delete-selection.md] — soft delete; INV-SEL-009 на internal не распространяется). Публичная ссылка /collection/{publicCode} при этом отдаёт 404 (UC-SEL-003 A1, INV-SEL-009 — публичная часть). Завершается.

A2. referral недоступен

  1. SharePopover деградирует до ссылки без ?ref= — шаринг подборки не блокируется реферальной системой (UC-SEL-002 A1). Возврат к основному потоку, шаг 4.

A3. Клиент регистрируется/оставляет заявку в окне привязки

  1. Реферальная связь «кто привёл» и лид формируются по правилам referral + crm (Имя пустое / регистрация в окне / владелец-агент → лид в CRM у агента-владельца; glossary «Реферальная связь»).
  2. Это зона referral/crm (их UC); selections в формировании связи не участвует. Завершается.

A4. Клиент уже авторизован

  1. Cookie referralUrl ставится, но реферальная связь не возникает (правила привязки referral отсеивают существующих пользователей вне окна — UC-SEL-002 A3); подборка открывается как обычно (UC-SEL-003 A4 — любой актор). Завершается.

Постусловие

  • selections выполнил операцию share: состав подборки не изменился, но опубликовано pin.selections.selection-shared.v1 (Action = Shared) — факт для ленты лида crm (SelectionAttached) и истории действий user-activity.
  • Публичная ссылка рабочая и не протухает (PublicCode неизменяем, INV-SEL-005).
  • Владение связью «подборка ↔ лид» и её материализация в таймлайн — у crm (потребитель события); selections события не потребляет. Факт создания подборки дал selection-created.v1 ранее (UC-SEL-001).

Возможные Ошибки

КодHTTPУсловиеПоведение клиента
UNAUTHORIZED401GET /selections / POST /selections/{id}/share без/с просроченным JWTrefresh identity → повтор (агент)
SELECTIONS_NOT_FOUND404POST /selections/{id}/share: подборка чужая (INV-SEL-002, чужое существование не раскрывается) или удалена (INV-SEL-009)«Подборку нельзя отправить»
SELECTIONS_NOT_FOUND404internal-резолв: SelectionId не существует вовсе (не «удалена»)CRM: «подборка недоступна»
SELECTIONS_NOT_FOUND404публичное открытие удалённой/несуществующей (UC-SEL-003 A1)«подборка не найдена»
FORBIDDEN403internal-вызыватель не m2m crm (policy InternalCrmOnly)— (дефект интеграции)
5xx от referralРеф-ссылку получить не удалось (A2)Деградация: ссылка без ?ref=

Soft-deleted подборка через internal — не ошибка: 200 + IsDeleted = true (A1).

Доменные События

СобытиеКогда поднимаетсяSubscribers
SelectionSharedEventОперация share (POST /selections/{id}/share, шаг 2) — агент отправил подборку клиентуПубликуется как интеграционное SelectionSharedKafkaEvent

Kafka Интеграционные События

СобытиеTopicКогда публикуетсяConsumers
SelectionSharedKafkaEventpin.selections.selection-shared.v1Шаг 2: агент отправил подборку клиенту (Action = Shared) — атомарно с фиксацией операции sharecrm (лента лида SelectionAttachedcrm/consumers/selection-shared.md), user-activity (ActionEvent «отправил подборку»)

Контракт события — ../events/selection-shared.md. selection-created.v1 для этой подборки был опубликован ранее при её создании (UC-SEL-001) — отдельное событие, не в этом сценарии.

Acceptance Criteria

#КритерийПроверка
AC1GET /selections self-scoped: агент видит только свои подборки, чужие не возвращаютсятест изоляции record-level (обязателен)
AC2GET /internal/selections/{id} резолвит подборку, включая soft-deleted (IsDeleted = true, не 404)контракт-тест internal-области
AC3Несуществующий SelectionId → internal 404 (отличим от soft-deleted IsDeleted = true)негативный тест
AC4Клиент открывает подборку по publicCode без данных владельца (OwnerUserId в ответе отсутствует)контракт-тест публичной проекции (UC-SEL-003 AC2)
AC5Операция POST /selections/{id}/share публикует selection-shared.v1 (Action = Shared, AgentUserId/InvestorUserId/SelectionTitle/PropertyObjectsCount); состав подборки при этом не меняетсятест публикации события + неизменности состава
AC6PublicCode остаётся рабочим после прикрепления и правок подборки (INV-SEL-005)интеграционный тест

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

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

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

  • Domain: ../domain/selection.md (PublicCode INV-SEL-005, masking OwnerUserId, soft delete INV-SEL-009); ../domain/selection-snapshot.md.
  • API методы (будущие пути по конвенции): ../api/public/list-my-selections.md (список агента), ../api/public/get-selection-by-code.md (открытие клиентом), ../api/internal/get-selection.md (резолв для CRM), ../api/public/delete-selection.md (soft delete).
  • Смежные UC: UC-SEL-002 (шаринг ссылки), UC-SEL-003 (открытие клиентом), UC-SEL-001 (создание подборки).
  • События: ../events/selection-shared.md (контракт selection-shared, публикуется операцией share), ../events/README.md (карта топиков); контракт создания — ../events/selection-created.md.
  • Границы: ../README.md; будущие пути (по конвенции): docs/06-services/crm/README.md (владеет связью «подборка ↔ лид»), docs/06-services/referral/README.md (реф-ссылка SharePopover), docs/06-services/user-activity/README.md (ActionEvent, ADR-0054), docs/06-services/catalog/README.md (display-данные объектов недвижимости).
  • Решения: ADR-0052 (вне tenant), ADR-0054 (ActionEvent user-activity), ADR-0056 (PostgreSQL-only).